Skip to content

TypeScript and JSX

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

TypeScript and JSX

TypeScript and JSX are the two places where the JavaScript grammar turns genuinely ambiguous for a recursive-descent parser, and es-parser handles both the same way: it parses speculatively and backtracks when a guess doesn't pan out. This is a syntactic parser — it parses the full TS surface grammar but does no type checking. Two backtracking mechanisms carry the weight: state snapshots and a token-rewrite log.

The TypeScript surface

typescript.zig parses the full type grammar. A tour, with the AST tags it produces:

Types and operators — union A | B (ts_union_type, typescript.zig:253), intersection (ts_intersection_type, :262), conditional T extends U ? X : Y (ts_conditional_type, :142), array and indexed-access (ts_array_type / ts_indexed_access_type, :271), typeof / keyof (:342, :378), infer T (ts_infer_type, :389), mapped types { [K in T as U]: V } (ts_mapped_type, :1314), template-literal types (ts_template_literal_type, :1461), tuples with labeled members [a: T] (ts_named_tuple_member, :1223), constructor types (ts_constructor_type, :1402), and predicates x is T / asserts x (ts_type_predicate).

Declarationsinterface (ts_interface_decl, :1664), type aliases (ts_type_alias_decl, :1783), enum (ts_enum_decl, :1871), and namespace / module (:2027). The module X {} keyword form emits a TS1540 warning per name segment, not a parse error (:2058).

Function and method type syntax — type-parameter lists <T extends U = D> (ts_type_parameter, :1530), in / out variance (:1551), and the interface member kinds, which are distinct tags so consumers need no charCode heuristics: ts_call_signature, ts_construct_signature, ts_method_signature, ts_property_signature, ts_index_signature (:2141).

Expression-level TSexpr as T / expr satisfies T (ts_as_expr / ts_satisfies_expr, expressions.zig:184), the non-null assertion expr! (ts_non_null_expr, :202), the angle-bracket assertion <T>expr (ts_type_assertion, :7290), instantiation expressions expr<T> (ts_instantiation_expr, :218), parameter properties (ts_parameter_property, parser.zig:6383), and decorators (@expr) — parsed and placement-checked (e.g. TS1206), but the decorator expression is consumed without a retained AST node.

Backtracking 1 — state snapshots

Two granularities:

checkpoint() / restore(saved)        position-only — saves tok_i; cheap
                                     lookahead that appends nothing   (parser.zig:8796)
saveSpeculative() / restoreSpeculative(s)
                                     full trial parse — snapshots
                                     {tok_i, diag_len, nodes_len, extra_len},
                                     truncating them on failure       (parser.zig:8805)

The full snapshot is used wherever a trial parse can append nodes that must be undone: conditional type vs. constraint (does a ? follow the extends?), parenthesized type vs. function type, generic arrow vs. type assertion. On failure it rewinds the cursor and shrinks the diagnostics, nodes, and extra_data buffers back to the snapshot.

Backtracking 2 — the token-rewrite log

The hard problem is that >>, >>>, >=, >>=, >>>=, and << are single tokens, but inside generics a run of >s has to 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, so the parser rewrites the token in place and logs the rewrite so it can be undone:

// src/parser.zig:217
pub const TokMut = struct { idx: u32, tag: TokenTag, start: u32 };

To peel one > off a >>, expectClosingAngleBracket retags it to greater_than and advances its start by one byte so the remaining > stays valid (typescript.zig:2584); << is split into < the same way in parseTypeArguments (:1619). The crucial detail: the in-place rewrite always happens — tags_ptr and tok_starts_ptr are cached as mutable column pointers (parser.zig:224, :232), so the edit is O(1) and visible to the next peek. recordTokMut only logs the rewrite, and only while the record_tok_muts flag is set (parser.zig:8675), which it is during speculation. So undoTokMuts can restore the original token stream byte-for-byte after a backtrack, while a rewrite on a committed path is permanent and intentionally unlogged.

Type-position semantics

Type syntax participates in scope analysis without polluting value semantics. A user-defined type name emits a type_read reference — so an unused-vars rule won't flag a type-only import — while the built-in keyword types (string, number, any, …) emit no reference and become keyword type nodes (typescript.zig:681). Type parameters emit a type_param declare when the position calls for it (emit_fn_type_params), so generic scopes resolve (typescript.zig:1587).

TypeScript declaration merging

Interfaces, namespaces, enums, and overload signatures may legally redeclare the same name. es-parser handles that bluntly: the redeclaration early-error pass checkRedeclarations bails out for any TypeScript AST — if (ast.is_ts) return &.{}; (event_resolver.zig:1411) — so duplicate TS bindings never reach the duplicate-binding check. The consumer-visible consequence is that diagnose_redeclare produces no diagnostics on .ts / .tsx / .d.ts input.

JSX

jsx.zig parses JSX once the lexer is in JSX context (see Lexer and Tokens).

Elements and fragments. parseJsxElement (jsx.zig:30) produces jsx_element (opening + children + closing), jsx_self_closing for <x/>, or jsx_fragment for <>…</>. The closing name is validated against the opening one (jsxNameMatches, :701). Names may be simple (jsx_identifier), dotted (Foo.Barjsx_member_expr), namespaced (a:bjsx_namespaced_name), or hyphenated (aria-label, recovered via token adjacency).

Attributes and children. jsx_attribute and jsx_spread_attribute for {...x} (jsx.zig:579). Children (parseJsxChildren, :377) coalesce text into one jsx_text_node, record pure-whitespace runs as jsx_gap_node, wrap {expr} as jsx_expression_container (with jsx_empty_expr for {}), and handle {...expr} as jsx_spread_child.

Component references. emitJsxComponentRef (jsx.zig:743) emits a read reference only for an upper-case-initial name (<Foo/>) or the root object of a member name (<a.b/> → a ref to a); lowercase intrinsics (<div/>) and namespaced names emit none.

The TSX < ambiguity

In .tsx, a < starting a primary expression is ambiguous between JSX and a type construct, and the dispatch resolves it like this (expressions.zig:2703): if looksLikeTsxGenericArrow matches one of the ESBuild-style markers — <T,>, <T extends …>, <T = …> — it re-enters parseTsTypeAssertion, which tries a generic arrow and falls back to a cast; otherwise it routes to JSX. So a bare <Type>expr cast is never reached in TSX — that token shape simply goes to JSX, and the cast code isn't disabled. Inside an opening element the parser additionally tries parseTypeArguments for <Foo<T> /> and accepts the type-argument reading only when a plausible continuation follows (>, /, {, identifier, or keyword), restoring otherwise (jsx.zig:111). Both paths reuse the same << / >> token-splitting, so <Foo<<T>> resolves correctly.

Clone this wiki locally