-
Notifications
You must be signed in to change notification settings - Fork 0
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.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.
Declarations — interface (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 TS — expr 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).
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 bufferssaveSpeculative/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
extendsmay begin a conditional type or be a type-parameter constraint. The parser snapshots, consumesextends, parses the check type, and if no?follows, restores and treats theextendsas 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 Uinside a parenthesized type must not swallow an outer conditional's?; a save/restore around the?distinguishes the two.
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_topTo 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 syntax participates in scope analysis without polluting value semantics:
- A user-defined type name reference emits a
type_readreference (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_paramdeclare (gated by anemit_fn_type_params/equivalent flag per declaration kind), so generic scopes resolve correctly. -
namespace/modulenames emit anamespace_decldeclare; TS declaration merging means interfaces, namespaces, enums, type aliases, and overload function declarations are markedcanRedeclareso the resolver does not flag them as duplicate bindings. See Semantic Analysis.
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:b → jsx_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.
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.)
es-parser — MIT licensed. This wiki documents the implementation under src/; when a detail matters, the source is authoritative.