Skip to content

TypeScript and JSX

Eric San edited this page Jun 14, 2026 · 2 revisions

TypeScript and JSX

Who this is for: parser engineers. The two backtracking mechanisms (state snapshots and the token-rewrite log) are the core ideas; the JSX/TSX < disambiguation is the payoff.

TypeScript and JSX are the two places where the JS grammar becomes genuinely ambiguous to a recursive-descent parser, and both are handled with speculative parsing and backtracking. This page covers the TS type grammar, the two backtracking mechanisms (state snapshots and the token-rewrite log), and JSX.

TypeScript constructs

typescript.zig parses the full surface TS grammar (no type checking — this is a syntactic parser):

Types and type operators — union (A | B), intersection (A & B), conditional (T extends U ? X : Y), template-literal types (`p${T}s`), array (T[]), indexed access (T[K]), mapped types ({ [K in T]: V }, with readonly/? modifiers and as remapping), typeof/keyof, infer T (and infer T extends U), constructor types (new (…) => T), parenthesized types, tuples (with labeled members [a: T]ts_named_tuple_member, and spread/rest), type literals (object types), and unique symbol.

Declarationsinterface (with generic params, constraints/defaults, extends, and the member kinds below), type aliases, enum (with computed/string members), and namespace/module (including dotted and string module names).

Function/method type syntax — type parameter lists <T, U extends V = D> (plus const T parameters and in/out variance modifiers), type-argument lists, function types (p: T) => R, generic function types.

Interface member kinds are distinct AST tags so consumers need no charCode heuristics: ts_call_signature, ts_construct_signature, ts_method_signature, ts_property_signature, ts_index_signature.

Expression-level TSexpr as T, expr satisfies T, the non-null assertion expr! (ts_non_null_expr), the angle-bracket assertion <T>expr (ts_type_assertion, JS/TS-only, not in TSX), instantiation expressions expr<T> (ts_instantiation_expr), type predicates x is T and asserts x, parameter properties (constructor(public x: T)ts_parameter_property), import("mod").X types (ts_import_type), declare (ambient context), and decorators (@expr, standard and legacy experimental_decorators placement rules).

Backtracking mechanism 1 — state snapshots

Two snapshot granularities exist:

// position-only — cheap lookahead that appends no nodes
fn checkpoint(self) u32                 // saves tok_i
fn restore(self, saved: u32) void

// full speculative state — a trial parse that may append AST nodes/diagnostics
pub const SpeculativeState = struct { tok_i, diag_len, nodes_len, extra_len };
fn saveSpeculative(self) SpeculativeState
fn restoreSpeculative(self, snap) void  // rewinds cursor + truncates buffers

saveSpeculative/restoreSpeculative is used wherever a trial parse can emit nodes that must be undone on failure. Examples:

  • Conditional type vs. constraint. After a type, a extends may begin a conditional type or be a type-parameter constraint. The parser snapshots, consumes extends, parses the check type, and if no ? follows, restores and treats the extends as a constraint.
  • Parenthesized type vs. function type. (T) vs. (p: T) => R: try function parameters speculatively; if the parse fails or no => follows, restore and parse a parenthesized type.
  • Generic arrow vs. type assertion. <T>(x) => … (generic arrow) vs. <T>expr (assertion): parse <…>, and if () => follows, commit to the arrow, else restore and parse the assertion's operand.
  • infer T extends U inside a parenthesized type must not swallow an outer conditional's ?; a save/restore around the ? distinguishes the two.

Backtracking mechanism 2 — the token-rewrite log

The hard problem is that >>, >>>, >=, >>=, >>>=, and << are single tokens, but inside generics a run of >s must close several type-argument lists (Map<string, Array<number>> ends in one >> token that is really two >s). Re-lexing would be expensive and stateful. Instead the parser rewrites the token in place and logs the rewrite so it can be undone on backtrack:

pub const TokMut = struct { idx: u32, tag: TokenTag, start: u32 };
fn recordTokMut(self, idx)            // push (idx, old tag, old start) to tok_mut_log
fn undoTokMuts(self, log_top)         // restore every entry above log_top

To close one > out of a >> token (expectClosingAngleBracket):

.greater_greater => {
    p.recordTokMut(p.tok_i);
    p.tags_ptr[p.tok_i] = .greater_than;  // becomes a single '>'
    p.tok_starts_ptr[p.tok_i] += 1;        // start advances past the consumed '>'
}

The same idea splits <<< for nested generic function types in argument position, and decomposes >>>, >=, >>=, >>>= as needed. Because the parser caches tags_ptr/tok_starts_ptr as mutable column pointers, these edits are O(1) and visible to subsequent peek()s.

One subtlety worth knowing: the in-place rewrite always happens, but recordTokMut only logs it when the parser's record_tok_muts flag is set — which it is during speculative parses. So undoTokMuts(log_top) can restore the original tag/start for every rewrite performed since a snapshot (leaving the token stream byte-accurate after a backtrack), while a rewrite outside speculation is permanent and intentionally unlogged.

Type-position semantics

Type syntax participates in scope analysis without polluting value semantics:

  • A user-defined type name reference emits a type_read reference (so an unused-vars rule will not flag a type-only import), while built-in TS keyword types (string, number, any, …) emit no reference and become keyword type nodes.
  • Type parameters emit a type_param declare (gated by an emit_fn_type_params/equivalent flag per declaration kind), so generic scopes resolve correctly.
  • namespace/module names emit a namespace_decl declare; TS declaration merging means interfaces, namespaces, enums, type aliases, and overload function declarations are marked canRedeclare so the resolver does not flag them as duplicate bindings. See Semantic Analysis.

JSX

jsx.zig parses JSX once the lexer has supplied JSX-context tokens (see Lexer and Tokens).

Elements and fragments. parseJsxElement produces jsx_element (opening + children range + closing, via JsxElementData); a self-closing tag is jsx_self_closing; <>…</> is jsx_fragment. The closing tag name is validated against the opening name (jsxNameMatches). Names may be simple (jsx_identifier), dotted (Foo.Bar → nested jsx_member_expr), namespaced (a:bjsx_namespaced_name), or hyphenated (aria-label — a single jsx_identifier whose payload records the last token so the full text is recoverable).

Attributes. jsx_attribute (boolean, ="string", ={expr}, or =<Element/>) and jsx_spread_attribute ({...expr}).

Children. Consecutive JSX text tokens coalesce into a single jsx_text_node whose payload encodes the text span and any leading-whitespace gap; jsx_gap_node records pure-whitespace runs between non-text children (for layout-aware rules); {expr} is jsx_expression_container, {} is jsx_empty_expr, {...expr} (child position) is jsx_spread_child. Nested elements/fragments recurse.

Component references. emitJsxComponentRef emits a read reference only for an upper-case-initial direct name (<Foo/>) or the root object of a member name (<components.Button/> → reference to components); lowercase intrinsic tags (<div/>) and namespaced names emit none.

The JSX-vs-type-argument ambiguity (TSX)

In .tsx, <Foo<T> /> collides with the generic-instantiation reading. The opening-element parser snapshots full speculative state, tries parseTypeArguments, and accepts the type-argument reading only if it succeeds and the following token is a plausible continuation (>, /, {, identifier, or keyword). Otherwise it restores and lets attribute parsing proceed. This reuses the same <</>> token-rewrite splitting, so <Foo<<T>> resolves correctly. (Relatedly: a bare <Type>expr cast is never reached in TSX — at a < the primary dispatch routes to JSX unless looksLikeTsxGenericArrow matches the ESBuild-style markers <T,> / <T extends…> / <T = …>, which re-enter the shared parseTsTypeAssertion. The cast code isn't disabled; that token shape simply goes to JSX.)


Next: Semantic Analysis · AST and Memory Layout

Clone this wiki locally