013. Generating TypeScript Parsers
- Status: Implemented
- Author: @ornew
- Date: 2026-10-08
Summary
Generate TypeScript modules for a parser, without dependencies, from a grammar (pego gen -lang ts,
gen.TypeScript.Generate). They return the same trees and errors as the engine and the generated Go parsers
(008). The runtime is a module of its own, runtime.ts, the same for every grammar; the
parser of the grammar is parser.ts, and its types (-types) are types.ts.
Design
The Same Generator, Another Runtime
The TypeScript generator (internal/gen/tsgen/generator.go) is the Go generator with a different output: it compiles the
grammar with the engine, walks the same analysis results (memoized rules, left-recursion leaders, value-free twins,
plain rules, projected repetitions, first-character dispatch, the recognizer) and emits a function per parsing
expression and a function per action and predicate. It shares the expectation table, the naming and first with the
Go generator (both embed gen.Generator).
The runtime, internal/gen/tsgen/tsrt/runtime.ts, is a port of runtime/runtime.go, function for function: rule calls with
deferred memoization (firstCall, with the per-rule eager switch), left recursion by growing the seed, memo entries
keyed by variable values, expectation records isolated in memoized calls and kept for reuse, captures with frames and
the trail, attachCaptures and finish, @error and #recover, the Operators Rule loop, and every built-in function including
the fused concat. Keeping the two side by side makes a change to one easy to carry to the other; the parity test
catches what is not.
| Part | Contents |
|---|---|
runtime.ts |
tsrt/runtime.ts after its --- Runtime --- line, with the invocation work controls, tracing and profiling (work.ts, trace.ts, profile.ts). The same for every grammar and every option, so that one copy can be shared (-runtime import). It type-checks on its own. Every top-level declaration is exported. |
types.ts |
With -types: the declarations of the grammar's types. |
parser.ts |
The parts below, importing ./runtime.ts (or the module of -runtime-module) and ./types.ts, and re-exporting the names a user needs (Node, SyntaxError, marshal, ...). |
| Rule table | A constant per rule (R0, R1, ...; Q0, ... for the recognizer) with the engine's analysis results, and the table rules for parseRule |
| Parser expressions | One function per expression; literals, ., anchors and rule calls are called inline |
| Actions and predicates | A function each, of TypeScript expressions; lambdas become arrow functions |
| Public API | parse, parseRule, recognize (with -recognize), Node, SyntaxError, SyntaxErrors, marshal |
Files, Modules and Observation
parser.ts imports runtime.ts with the specifier ./runtime.ts, the file's own name, which Node.js (type stripping),
Deno, Bun and bundlers resolve as it is. tsc needs allowImportingTsExtensions to check it and
rewriteRelativeImportExtensions (TypeScript 5.7 and later) to emit .js specifiers. A parser generated with -runtime import imports the
module of -runtime-module instead, so that several parsers share one runtime (and one Node class). ES import
bindings are read-only, so the data of a grammar (rule descriptors, source information, initial variables, the
depth default, the sparse-memo choice and whether the parser is observable) lives in a Grammar object that
parser.ts creates with mkGrammar and passes to run, the runtime's entry point; the runtime itself holds no
per-grammar state. Tracing and profiling are in the runtime for every parser: a parser keeps the observation of
an invocation in Parser.observation, which is null for a plain call, and the paths that notify it
check for null. A parser generated without -observability rejects the trace and profile options. The
call sites that record skipped dispatches are the only observed code that depends on the option and are
generated per parser. The parser reads the hot runtime values it needs into fields of the Parser in its
constructor, so that a rule call does not read through the Grammar.
Where JavaScript Differs from Go
The runtime hides each difference, so that results are the engine's, not merely similar:
- Strings are UTF-16. Positions count code points or UTF-8 bytes, so the input is decoded first: into an
Int32Arrayof code points, or kept as UTF-8 bytes, with a table from positions to offsets in the string for node text (none when they coincide, as for ASCII). A string is parsed as its UTF-8 encoding (lone surrogates as U+FFFD); aUint8Arrayis decoded exactly as Go decodes it (utf8.DecodeRune: an invalid sequence is U+FFFD of one byte), whichTextDecoderdoes not do. In actions, string comparison orders by code point (Go's byte order) andlencounts code points or UTF-8 bytes. Literals carry both their code points and their bytes, computed by the generator with Go's conversions. In byte mode, Go's node text holds the raw bytes even when they are not valid UTF-8, so the runtime keeps each invalid byte b in text as the lone surrogate U+DC00+b (as Python'ssurrogateescapedoes): text stays injective,lencounts such a surrogate as one byte, comparisons order it as that byte, the S-expression form writes it as\xNN(asstrconv.Quotedoes), andmarshalwrites it as U+FFFD (asencoding/jsondoes). - Ints are 64-bit and wrap around in Go. An int is a
numberwhile it is a safe integer and abigintbeyond (normalized after each operation), so ordinary arithmetic stays on numbers, equal ints are always===(memo keys compare variable values with===), and overflow,MinInt64 / -1and the like give Go's results. Division of safe integers byMath.truncis exact;-0is turned into0. - JSON.
JSON.stringifycannot matchencoding/json(which escapes<,>,&, U+2028 and U+2029 and writes big ints exactly), somarshalwrites the engine's bytes;Node.toJSONgives the same structure forJSON.stringify.marshalandtoStringkeep the values left to write on a stack of their own: left recursion, left-associative Operators Rule operators andfoldlbuild trees as deep as the input is long without nesting calls, and writing them recursively overflowed the stack on trees the parse had built fine.JSON.stringifystill recurses (the guide says so). - Unicode versions differ.
toStringquotes text asstrconv.Quotedoes, which escapes the characters thatstrconv.IsPrintrejects. The JavaScript runtime's Unicode properties (\p{L}and so on) follow its own version of Unicode (Node.js 24 has Unicode 16, Go 1.27 Unicode 17: thousands of code points differ), so the runtime holds Go's table instead: the lengths of the alternating runs of rejected and accepted code points, in base 36, about 3 KB. The table is that of the Go that generates the parser. - No
panic. Fatal errors and evaluation errors are thrown as plain objects (Fatal,EvalError, notErrorsubclasses, so throwing them captures no stack trace), and caught whereruntimerecovers. Results areNode | nullon success andundefinedon failure, instead of(value, ok)pairs. - The stack is small. Rule calls recurse, as in Go, but Node.js's default stack holds only some hundreds to a few
thousand nested rule calls. The generated code calls literals and rules inline to save frames, the runtime turns a
stack overflow (a
RangeErrorin V8 and JavaScriptCore, anInternalError"too much recursion" in SpiderMonkey) into an error (nesting too deep: the JavaScript stack overflowed at N rule calls), and the guide shows how to run the parser with a larger stack. This is the one documented way in which a generated TypeScript parser can reject input that the engine accepts.
API
parse(input, unit?) returns { node, error } instead of throwing, because a parse that recovered with #recover
has both a tree and errors, as Go's (node, err) does. Errors are SyntaxError and SyntaxErrors (subclasses of
Error, whose message is Go's Error()), or a plain Error for runtime errors in actions and too deep nesting.
gen.TypeScript is a configuration of its own rather than a setting of gen.Go: TypeScript needs no Go module or
package name and has its own RuntimeModule. Settings both languages have, such as Recognize, are fields of both,
and both implement gen.Generator. The initial TypeScript implementation rejected Types; typed conversion was added later
(see design 030). The command keeps one gen command with -lang go|ts.
The module uses only erasable TypeScript (no enums, namespaces or parameter properties), so Node.js, Deno and Bun run it
without a build step, and only the ES2020 library (for BigInt), so it needs no DOM or Node.js types.
Verifying Identical Behavior
TestGeneratedTSParsersMatchEngine (internal/engine/tsgen_test.go) generates a module for every grammar of the
corpus of the Go generator's test (the engine's test grammars, testdata/typed and examples/), plus a grammar of ints
and strings that exercises wrapping, big ints, negative division and code-point comparison, and the nested-parentheses
grammar exactly at and beyond the nesting limit. Node.js runs every input in both units, as bytes and (when valid UTF-8)
as a string, in a worker with a 1 GB stack. For each it compares with the engine (compiled without projections, as the
Go test does):
- the result as JSON, byte for byte (the node from
marshal, and the error message); - the S-expression form, byte for byte (
toStringagainstNode.String); - the error of
recognizeagainst the engine's recognition.
Besides the inputs of the corpus, it parses every prefix of each short input and the input without each of its bytes (variants that fail in many places, some of them not valid UTF-8). With variants of inputs up to 2,000 bytes instead of 40, the test compares 40,128 results; that run is too slow to keep, but it passes.
It also type-checks all generated modules with tsc --strict plus --noUnusedLocals, --noUnusedParameters,
--noUncheckedIndexedAccess, --exactOptionalPropertyTypes, --erasableSyntaxOnly and others, at --target es2020.
The test is skipped without node, and the type check without tsc.
Alternatives Considered
- A TypeScript VM for the bytecode (010). One runtime for all grammars, loading
.pegocfiles, and an iterative VM would have no stack problem. But an interpreter in JavaScript is slower than generated functions that the JIT compiles, and the generator had all the machinery already. - Compiling the Go runtime to WebAssembly. Exact by construction, but the module would carry a Go runtime, cross a boundary for every tree it returns, and not be TypeScript a user can read.
- Iterative rule calls. Would remove the stack limit, but every expression function would have to become a state machine; the generated code would be far larger and slower. A larger stack is easy to get where it matters.
- Exceptions for syntax errors. Idiomatic in JavaScript, but a recovered parse has a tree and errors at once.
Limitations
- Typed values are now available in generated TypeScript with
-types: the generator exports declarations and configured-startparseASTconversion (guide, design 030). Streaming and incremental parsing are not generated. - Deep nesting is limited by the JavaScript stack (see above) as well as by the limit of 100,000 nested rule calls.
- In byte mode, Go concatenates bytes:
text($a) + text($b)of two halves of a character is that character, while the runtime's two surrogates stay two surrogates. Only input that is not valid UTF-8 can tell. JSON.stringifywrites ints beyond the safe integers inexactly, and in byte mode writes an invalid input byte in text as the surrogate that stands for it (where the engine's JSON has U+FFFD);marshalwrites both as the engine does.