Tu Language Reference

A practical reference for every Tu syntactic form that compiles today. Covers the language through the M6.11 line (async / Suspense / streaming SSR) — see DEFERRED for work still in flight.

Tu is a single-pass, expression-oriented language that compiles to a small ESM module per .tu file. The compiler also emits a TypeScript shadow with preserved type annotations and an inline V3 source map. Every top-level binding can be read from JS / TS via standard import.


File structure

A .tu file is a sequence of top-level statements separated by whitespace. There’s no statement terminator (no ; at the top level; ; is reserved for type-spans only).

Allowed top-level forms:

  • let X = … — module-private value binding
  • export let X = … — public value binding
  • let X: T = … / export let X: T = … — annotated binding
  • type X = … / export type X = … — type alias
  • import { … } from "./other.tu" — named import
  • export { … } from "./other.tu" — named re-export

Comments use // (line) — there is no block-comment form yet.

// A complete file:
import { Card } from "./Card.tu"

export let count = 0

export type Pair = { x: number, y: number }

export let App = () => div { Card("hi") }

Bindings

let X = value

let declares a top-level binding. The value is evaluated at module initialization. The classification (state / computed / function) drives how reads compile:

  • let X = (…) => … — lambda. X reads as a plain identifier; calls invoke the function as expected.
  • let X = computed(expr) — computed cell. X.get() returns the derived value; the runtime auto-tracks dependencies and invalidates on change.
  • let X = anything else — state cell. Wraps the value in Signal.State; X.get() reads, X = newVal writes (codegen rewrites to X.set(newVal)).
let count = 0                            // Signal.State<number>
let doubled = computed(count * 2)        // Signal.Computed<number>
let inc = () => count = count + 1        // function

Reads inside a lambda body that reach back to a top-level state / computed cell get an automatic .get() injection:

export let App = () => p { count }
// emits: () => h("p", {}, [count.get()])

A lambda parameter shadowing a top-level cell stays as a plain identifier:

let name = "outer"
export let G = (name: string) => p { name }
// emits: (name) => h("p", {}, [name])  -- no .get()

export let X = value

Same as let, but the binding is part of the module’s public surface. Only export let declarations appear in the emitted .d.ts.

Annotated bindings

Add : T between the name and = to give the binding an explicit TS type. The annotation is captured as a raw source slice and threaded into the TS shadow, with appropriate Signal wrapping:

Tu source Emitted TS const type
let App: () => string = … const App: () => string
let count: number = 0 const count: Signal.State<number>
let total: number = computed(…) const total: Signal.Computed<number>
let cell: Signal.State<MyShape> = … const cell: Signal.State<MyShape> (no double-wrap — codegen detects the explicit Signal prefix)

The annotation is erased in JS-mode emission.

Local let inside a block

A let X = expr written inside a block body declares a block-scoped const — it does NOT become a Signal cell. Useful for closures and small computations:

let Greet = (name: string) => {
  let greeting = "Hello, " + name + "!"
  let upper = greeting   // any plain JS expression
  p { upper }
}

Local lets shadow same-named top-level cells inside that block (reads emit as bare idents, no .get() injection). Type annotations are supported via the same raw-slice mechanism.


Type aliases

type Pair = { x: number, y: number }
export type Color = "red" | "green" | "blue"

The RHS is captured as a raw source slice (Tu doesn’t parse types itself — the TS compiler does at the tu check / IDE step). Type aliases erase entirely from JS-mode output.

type is a contextual keyword: it triggers only when followed by Ident = at statement boundary. So a lambda param named type still works:

export let f = (type: string) => p { type }

Values

Literals

"a string"           // StringLit
42                   // NumberLit
[1, 2, 3]            // ArrayLit
[]                   // empty ArrayLit
{ x: 1, y: 2 }       // ObjectLit
{ "data-id": 7 }     // ObjectLit with string key
{}                   // empty ObjectLit

Strings support common escapes: \n, \t, \r, \", \\. Numbers are integers only at the syntactic level (decimal-point parsing not in V1).

{ … } is disambiguated against the block form (see Blocks): an opener of { }, { Ident :, or { String : parses as an object literal. Anything else ({ x }, { let y = 1; y }, { tag(...) }) stays a Block. Shorthand ({ x }{ x: x }), computed keys ({ [k]: v }), and spread ({ ...rest }) aren’t recognized yet — see docs/DEFERRED.md.

When an object literal appears immediately after => in a lambda body, the codegen wraps it in parens (() => ({ x: 1 })) so JS doesn’t read it as a block.

Identifiers

A bare identifier reads the binding by that name. Resolution follows JS-style lexical scope (lambda params, for binders, then top-level lets).

Member access

obj.x
make(n).field
nested.outer.inner

Postfix .Ident reads a property from any expression that yields a value. The . here doesn’t collide with the prefix-dot ClassRef syntax — class: .card keeps its existing meaning because the dot sits at expression head, not after a returned value. Cell reads inject .get() on the leaf ident only: origin.x compiles to origin.get().x when origin is a state cell.

Arithmetic and comparison

a + b   a - b   a * b   a / b   a % b
a == b  a != b   // -- compile to JS strict ===, !==
a < b   a <= b   a > b   a >= b

Pratt precedence: * / % > + - > comparison > equality.

Lambdas

(x) => x + 1
(x: number) => x + 1
(name: string, age: number) => p { name }
() => p { "hi" }
(x: number): string => "ok"               // return-type annotation
(): Map<string, { v: number }> => empty   // generics + nested types OK

The body is any expression (including a Block). Param and return type annotations preserve through TS-mode emission for inference. JS-mode strips both.

Calls

foo(arg, another)        // CallExpr — positional args

Identifiers followed by ( and positional args (no Ident: at the front) parse as call expressions. The result is whatever the function returns.

Blocks

{
  someStmt
  anotherStmt
  finalExpr      // value of the block
}

Blocks compile to an IIFE when there are 2+ statements (each non-final statement evaluated for side effects, the final one is the block’s value). A 1-statement block compiles to (stmt). Note that {} parses as an empty object literal, not an empty block — write a one-statement block explicitly (e.g. { undefined }) if you really mean “evaluate to undefined.”


Markup (tag-calls)

Markup uses a trailing-closure DSL. Capitalization is the discriminator between HTML tags and user components (React/JSX convention):

  • Lowercase identifier → h("tag", props, children) (HTML element)
  • Uppercase identifier → Callee(args, [children]) (component function call). tsserver sees the call as a real function — hover, goto-definition, and completion all work on the component name.

Bare tag with children

div { "hello" }
ul {
  li { "a" }
  li { "b" }
}

Tag with named props

button(onClick: handler, disabled: false) { "click" }
img(src: "logo.svg", alt: "logo")

A (Ident: …) opener disambiguates tag-calls from positional calls. With no explicit-prop opener (and no children block), it parses as a positional call instead.

Component invocation

Components support two calling conventions (M6.1):

1. Named-arg form — props delivered as a single object, like HTML:

let App = () => Card(title: "Alice", footer: "© 2026") {
  p { "Body content" }
}

The receiver gets a single props arg; trailing children block is auto-merged in as props.children:

let Card = (props) => .card() {
  h2 { props.title }
  props.children
  if (props.footer) { footer { props.footer } }
}

All props are optional by construction — call Card(), Card(title: "x"), or Card(title: "x") { p { "body" } } all work.

2. Positional form — legacy M5.x shape, kept for backward compat:

let Card = (name: string, children: Child[]) => .card() {
  h2 { "Hello, " name "!" }
  children
}
let App = () => Card("Alice") { p { "Body content" } }

The parser disambiguates by peeking the first token after (: an Ident : opener triggers named-arg; anything else stays positional.

children is an array of children that the runtime’s flatten step splices into the parent’s children list at render time. Use Child[] (not VNode[]) for the type — Child = VNode | string | number | null | undefined | Child[] reflects the runtime contract, and a component body that ends in a style block returns an array fragment, which VNode[] would reject. Both Child and VNode are auto-imported from @tu-lang/runtime in TS-mode emit, so the annotation resolves without an explicit user import.

Fragment

Fragment { … } from @tu-lang/runtime lets a component return multiple sibling vnodes without an enclosing wrapper element (React’s <>…</>):

import { Fragment } from "@tu-lang/runtime"

let Layout = (title: string, children: Child[]) => Fragment {
  header { h1 { title } }
  main { children }
  footer { "© 2026" }
}

Pug-style class shorthand

.card { "body" }                       // <div class="card-tu-h">body</div>
.card.shadow() { "body" }              // multi-class — class="card-tu-h shadow-tu-h"
.card(tag: "section") { "body" }       // override default `div`

The leading .foo chains gather class refs; an optional (tag: "literal") overrides the synthetic tag (must be a string literal). Adding an explicit class: prop is a compile error — the shorthand already binds class.

Children

Inside a { … } children block, allowed shapes are:

  • text (StringLit, NumberLit)
  • identifier reads
  • nested tag-calls / call-exprs
  • arithmetic / comparison
  • if / for / style / ClassRef
  • array literals (flatten via the runtime)

Lambdas, bare blocks, and assignments are NOT valid as direct children — Tu rejects them at parse time.


Control flow

if / else

if (count == 0) { "no items" }
else if (count == 1) { "1 item" }
else { "many items" }

if is an expression. else is optional (undefined fallthrough). Else-if chains stay flat.

for

for item in items {
  li { item }
}

item is the loop binding. items must be iterable at runtime. Compiles to Array.from(items, (item) => …). The iter-expression’s tail { … } is NOT treated as a tag-call children block during this parse — the trailing brace belongs to the loop body.


Reactivity

Tu wraps non-lambda top-level lets in TC39 Signal.State. Reads inside lambda bodies auto-inject .get(); writes via = rewrite to .set(…):

let count = 0
let inc = () => count = count + 1   // emits: count.set((count.get() + 1))

computed(expr) cells are invalidated when any cell read by expr changes. Assigning to a computed cell is a compile error.

mount(thunk, container) from @tu-lang/runtime wires a thunk into the DOM and re-renders on cell mutation. The keyed diff reuses element identity across renders (M1.7); LIS-based reorder minimizes moves on long-range list shuffles (M1.15).

hydrate(thunk, container) (M4 V1) is the SSR counterpart: instead of creating new DOM, it adopts an existing subtree (typically rendered by renderToString on the server) and only attaches event listeners / DOM-property props that SSR couldn’t serialize. Subsequent renders run the normal patchChildren diff. Adjacent text vnodes that the server fused into a single Text node are split during hydration so cell updates can target individual fragments (p { "count = " count } keeps the static prefix Text node untouched when count ticks).

defineCustomElement(thunk, tagName) (M4.1) registers a Tu thunk as a standard Custom Element. The element mounts on connectedCallback, stops on disconnectedCallback, and re-renders reactively while connected. V1 caveats: the thunk’s reactive scope is the module’s top-level cells (multiple instances share state), and HTML attributes don’t auto-bind to Tu cells yet.


Style block

let App = () => {
  div(class: .card) { "hi" }
  style {
    .card { padding: 1rem; }
  }
}

A style { … } block emits an <style> HTML element sibling to the main component vnode. The CSS body is preserved verbatim (Tu doesn’t parse CSS).

Top-level rules must be class-rooted (M5/D)

Every top-level rule’s selector list must start with . (a class selector), :global(…) (escape hatch), or @ (at-rule like @media). Element selectors at top level (p { … }) raise a compile error to prevent global bleed:

style {
  .card { padding: 1rem; }                  // ✅ class-rooted
  .card .title { font-size: 1.25rem; }      // ✅ compound, still rooted
  :global(.legacy-modal) { z-index: 9999; } // ✅ escape hatch
  @media (min-width: 600px) { … }           // ✅ at-rule

  p { color: red; }                         // ❌ compile error
}

For element selectors that only apply within a class, switch to CSS4 nesting (modern browsers handle natively):

.card {
  padding: 1rem;
  > h2 { font-size: 1.25rem; }   // applies to .card > h2
  &:hover { background: #eee; }  // applies to .card:hover
}

Scoped classes (dual-name injection — M5/F)

When a component contains a style block AND any .classRef references in the markup, Tu hashes every declared class with a per-component FNV-1a suffix (-tu-{6 hex}). The markup carries both the original name and the hashed one (space-joined); CSS selectors use the hashed form only:

let Card = () => {
  div(class: .card) { "hi" }
  // → class="card card-tu-a1b2c3"  (original + hashed)

  style { .card { padding: 1rem; } }
  // → .card-tu-a1b2c3 { padding: 1rem; }  (hashed only)
}

Two components declaring the same class name get different hashes — the component-scoped styles don’t bleed. The unhashed name on markup lets global CSS / dev-tool inspection / framework theming layers still target .card if needed.

:global(.foo) escape hatch

style {
  .card { padding: 1rem; }
  :global(.legacy-modal) { z-index: 9999; }
  .card :global(.icon) { color: red; }
}

Classes inside a :global(...) wrapper bypass the hash. The wrapper itself is stripped from the output. Compound selectors mix freely: .card :global(.icon) becomes .card-tu-h .icon.

ClassRef syntax

Outside a style block, .foo is a reference to a declared class. Three shapes:

  • class: .foo — bind a single class
  • class: .foo.bar — bind multiple classes (space-joined at runtime)
  • .foo() { … } — pug shorthand: synthesizes div(class: .foo) { … }

ClassRefs to classes NOT declared in the surrounding component’s style block raise a compile error. Outside any scoped component, ClassRefs are also disallowed (no place to hash to).


Markdown block (M6.3)

markdown { … } is a special-form block — sibling shape to style { … } — that lets Tu source mix prose alongside markup. The body is raw markdown; the compiler runs it through markdown-it at build time and emits the result as a single static-HTML vnode (M6.0 path), so there’s no markdown parser at runtime.

let About = () => .page() {
  h1 { "About" }
  markdown {
    Tu is a **reactive UI language** built around the trailing-closure
    DSL pattern. See the [language reference](/LANGUAGE) for the full
    syntax map.

    - One bullet
    - Another bullet

    \`\`\`ts
    const x: number = 42
    \`\`\`
  }

  style { .page { padding: 1rem; } }
}

Notes:

  • The block body is dedented before parsing so 4-space indents from surrounding Tu source don’t trip CommonMark’s “indented = code block” rule.
  • Brace-balanced lexing: { and } characters inside the markdown body must come in matching pairs; backtick-fenced code blocks (```) and inline backticks are skipped over so braces inside code don’t unbalance the count.
  • The output is wrapped in <article class="tu-markdown"> so consumers can style the prose container with one selector.
  • No interpolation in V1 — the markdown body is purely literal. Mixing Tu cells into prose comes later (likely via a ${expr} form that splits the block into static + dynamic spans).

Runtime + platform packages

Tu’s runtime is split into two packages so a .tu file that doesn’t intend to run in a browser can never accidentally pull DOM impls (M6.10):

  • @tu-lang/runtime — the universal half. Signal, h, Fragment, VNode, Child, renderToString, renderPage, renderPageHtml, renderToStringAsync, renderPageAsync, renderToStream, Suspense, TuRenderError. Compiled Tu auto-imports Signal / h / Fragment from here. Safe to use from Node, edge runtimes, Cloudflare Workers — anywhere without a document.
  • @tu-lang/dom — the browser half. mount, hydrate, defineCustomElement, plus typed re-exports of the standard DOM types your Tu code touches (Event, MouseEvent, HTMLInputElement, Node, Element, RequestInit, AbortController, …). Anything that touches document lives behind an explicit import { … } from "@tu-lang/dom".

Typical browser entry:

import { mount } from "@tu-lang/dom"
import { App } from "./App.tu"

mount(() => App(), document.getElementById("app"))

Typical SSR entry:

import { renderPageAsync } from "@tu-lang/runtime"
import { Page } from "./Page.tu"

let html = await renderPageAsync(() => Page(), { title: "Hi" })

The split is enforced at the runtime-function layer today. Strict type-level isolation (dropping lib.dom from the LSP shadow so unused DOM globals can’t sneak in) is tracked in DEFERRED.

External JS escape hatch (M6.9 / M6.10.1)

For interop with browser APIs, third-party JS, or anything outside Tu’s grammar, declare a typed bridge with external JS:

let inputValueOf = external JS (e: Event): string {
  const t = e.target
  return t && 'value' in t ? String(t.value) : ''
}

let App = () =>
  input(onInput: (e: Event) => state = inputValueOf(e))

The block body is raw JavaScript — Tu doesn’t parse it, just emits it verbatim into the compiled module. The signature (e: Event): string is a regular Tu type annotation and propagates into the TS shadow, so call sites get full type checking without Tu having to understand the body.

Use cases:

  • DOM-level extraction that needs a cast Tu doesn’t have yet (event.targetHTMLInputElement.value).
  • Native browser APIs not surfaced through @tu-lang/dom (URL, crypto.subtle, IntersectionObserver, …).
  • Interop with non-Tu npm packages whose shape Tu’s TypeScript emit doesn’t import-friendly.
  • Performance escape hatches where you want to drop into hand-written JS without leaving the file.

Inline object-shape return types are supported (M6.10.1):

let measure = external JS (xs: any[]): { ms: number; out: any[] } {
  const t0 = performance.now()
  const out = xs.slice().sort()
  return { ms: performance.now() - t0, out }
}

The { after : is parsed as a type literal, not a body opener — the parser disambiguates by the immediately-preceding token.


Imports / exports

Named import

import { Card, Header } from "./Card.tu"

Relative .tu paths are resolved by the compiler. The import maps to a standard ESM import in the JS output (and .ts extension in the TS shadow so tsserver resolves the sibling shadow file).

Re-export

export { Card } from "./Card.tu"

Same as import { Card } followed by export { Card }, but in one statement and without binding Card locally.

Cross-.tu reactivity (M2.3)

When the LSP, tu check, or the @tu-lang/vite plugin compile a graph of .tu files, they pre-classify each file’s export let bindings (state / computed / function). The compiler uses that classification to inject .get() for imported state cells, so:

// cell.tu
export let count = 0

// App.tu
import { count } from "./cell.tu"
export let App = () => p { count }   // emits: count.get()

works as expected. Outside a graph context (the standalone compile() call from a string), imports default to “function” classification — which emits a bare ident at the read site.


Type system

Tu’s type system delegates to TypeScript via an emitted shadow file. Each .tu compiles to:

  • A JS module (runtime) — types erased
  • A TS module (typecheck / .d.ts emit) — types preserved

The TS shadow looks like normal TypeScript: Signal.State<T> cells, Signal.Computed<T> cells, function lambdas with declared param types, type X = … aliases, and (for exported lambdas with all-typed params) a synthesized interface ${Name}Props { … }.

tu check <file> runs the shadow through tsserver and pretty-prints diagnostics back at the .tu source. The @tu-lang/lsp package exposes the same logic plus hover, completion, definition, and rename — all token- ranged so squiggles / underlines target individual identifiers.

.d.ts emission via tsc --emitDeclarationOnly over the shadow gives downstream TS consumers a clean declaration file with only the public export let / export type surface.


What’s not in V1

See DEFERRED for the live list. As of M6.11 the notable gaps are:

  • Default exports — TBD; revisit when component-as-file becomes idiomatic.
  • Lifecycle hooks + element ref sugar — no onMount / onUnmount; no Vue-2-style explicit ref.
  • Router — Tu has no built-in routing yet; tu-shu loads pages by file discovery, but there’s no general router (M7 milestone).
  • as type assertion — no expr as Type cast; route through external JS for now.
  • Per-component fine-grained HMR — full module re-import on save.
  • Local reactivity — full thunk re-runs on cell mutation; per-binding patches are deeper rework.
  • CSS4 nesting / @layer / @scope — needs a real CSS parser; the regex-based scanner handles most flat selectors today.
  • Style ↔ JS state interop — design pending; cells don’t auto-bind to CSS variables yet.
  • Qwik-style resumability — hydrate re-runs the first-frame thunk; serialized listener references are future work.

What landed in M6.11 (async + SSR)

  • renderToStringAsync(node) — awaits any Promise child during the SSR walk. Child gained a Promise<Child> member; sync renderToString now throws TuRenderError on Promise children (was silently emitting [object Promise]).
  • renderPageAsync(thunk, options) — thunk may be a sync function or an async lambda; the body is rendered via renderToStringAsync and assembled into a complete HTML document.
  • Suspense({ fallback, children }) — boundary primitive that catches Promise rejections inside its body and emits the fallback Child instead. Boundaries compose; sync renderToString of a Suspense renders fallback verbatim. Tu call-site is named-arg form: Suspense(fallback: div { "Loading…" }) { AsyncChild() }.
  • renderToStream(thunk, options) — Web ReadableStream<Uint8Array> ready to pipe into a Response. Shell + sync body + per-boundary <div data-tu-suspense="N">…fallback…</div> placeholders flush first; resolved bodies stream later as <template id="S:N">…</template><script>$tu_replace("N")</script> chunks in resolution order. Rejected boundaries leave the placeholder (with its fallback content) in place.

Examples:

  • examples/ssr/ — sync renderToString + client-side hydrate round-trip.
  • examples/suspense/ — async + Suspense + streaming, both pipelines exercised end-to-end.

See docs/SSR-ASYNC-DESIGN.md for the design context behind these primitives.