030. Generate Typed TypeScript Values from Parse Trees
- Status: Implemented
- Author: @ornew
- Date: 2026-10-11
Summary
Allow pego gen -lang ts -types and gen.TypeScript{Types: true}
to generate interfaces, aliases and unions corresponding to grammar types (a grammar join type becomes a TypeScript union),
plus typed parse-result entry points. The first implementation runs
the existing Node parser once and converts its completed result into typed
values. Existing parse, parseRule, recognition and Node serialization stay
unchanged. Direct typed matching is a separate future optimization requiring
its own correctness and measurement gates.
Motivation
internal/gen/tsgen/generator.go previously rejected GenOptions.Types. Generated TS
returns Node values whose fields require runtime name lookup and consumer
casts. Generated Go already maps grammar types to public values through
internal/gen/gogen/types.go; its metadata and converter semantics provide the reference
for the new TS mapping. The feature acceptance criterion requires actual
consumer use, value equivalence and declaration exports, not merely compiling
a generated module.
The implementation is opt-in. Generated Go conversion remains the semantic reference for typed values; the focused and full-suite validation and measured tradeoffs are recorded below.
Goals
- Export every declared grammar type and alias with its own name from
types.ts, including Unicode names. - Give the configured start rule a static result type with accurate nullable fields, aliases and recovery values.
- Preserve the Node parser's whole-input, action, predicate, cut, recovery, LR/Operators Rule, error, Unicode, integer and position-unit contracts.
- Convert returned values after parsing, without changing the representations actions and field-reading predicates observe while parsing.
- Keep the generated module dependency-free, ES2020-compatible, and usable by runtimes that erase TS syntax.
The Unicode-name goal records the lexical contract at the time of this design. Grammar identifiers are now ASCII-only; Unicode input and string values remain supported.
Non-Goals
Scalar top-level rules remain rejected by the existing node-producing rule contract. Scalar fields and aliases remain supported. This proposal does not change type inference, grammar/saved formats, Go parsing, streaming, ready parsers, 32-bit portability, or recognition's result type. Named typed dispatch is a separate feature outside this initial API. This proposal does not promise faster typed parsing or introduce a public arbitrary-Node conversion API.
Design
Public API
Mirror the current TS position-unit and options-aware pairs:
export interface TypedParseResult<T> {
readonly value: T | null;
readonly error: Error | null;
}
export function parseAST(
input: string | Uint8Array, unit?: Unit,
): TypedParseResult<StartValue>;
export function parseASTWithOptions(
input: string | Uint8Array, options?: ParseOptions,
): TypedParseResult<StartValue>;
StartValue above denotes the inferred generated type, not a mandatory new
export. The initial entry points select the configured start rule only.
Existing generic named-rule parsing remains available through parseRule.
The options-aware entry point reads and validates options once before parsing,
including property getters. Invoke the internal resolved run path, rather
than resolving options and calling another options-aware public function.
Use the generated depth default, zero reset, safe-integer validation and units
already defined by the TS runtime. The unit entry point retains the existing
unit normalization.
Value Mapping
Prefer plain data objects and discriminated interfaces, with lowercase metadata matching existing TS Node conventions:
export interface Span { readonly start: number; readonly end: number }
export interface Match extends Span {
readonly type: "Match";
readonly text: string;
}
export interface Pair extends Span {
readonly type: "Pair";
readonly Key: Match | null;
readonly Value: Match | null;
}
Grammar struct fields currently must begin with an uppercase letter
(check.go), so lowercase type, start, end, text and message metadata
do not collide with valid fields. Preserve original grammar field spelling.
Terminal interfaces use their original type-name discriminator and text.
A recovered Error converts to a helper such as RecoveredError, containing
type:"Error", span, text and message. Keep diagnostic Error and
SyntaxErrors classes separate from this value interface.
Mapping details:
| Grammar value | TS representation |
|---|---|
| struct or terminal | generated interface; nullable where Go conversion can produce a nil pointer |
int field/alias |
`number |
string, bool field/alias |
string, boolean; absent nonoptional fields are "", false |
[]T |
readonly array of mapped elements; null for absent/nil list, distinct from an empty array |
| optional value | mapped value or null, including optional scalars |
| declared alias | exported alias of the complete target representation, preserving nullability |
| canonical declared node join | TypeScript union of converted members plus RecoveredError; nullable in zero/optional positions |
| CST or record | existing Node, preserving its Node representation |
mixed/open or undeclared joins, node, terminal, _ |
conservative TypedValue, following Go any-conversion and including null; inferred nil/never use this same recipe because recovery can produce Error values |
Generate these open-value unions from the known grammar declarations plus
Match, RecoveredError and CST Node. The _ mapping also includes
number|bigint, string and boolean. Conversion of a dynamically typed List stays
Node where Go's anyValue does; statically known list conversion returns an
array. Use unknown only if metadata cannot safely describe a permitted value.
A CST member in a join retains the Node class. Consumers first use
instanceof Node, then discriminate converted members by type; Node's broad
string type must not be advertised as providing complete literal narrowing.
Expose all declared aliases, including normalized duplicate joins, chained, scalar, optional, list and CST aliases. TS structural types do not need Go's union marker methods. Use the existing inferred metadata and normalization.
Mixed scalar/node joins require the existing Go any-conversion semantics,
not a pure-node join selector. For example, Word | int preserves canonical
number/bigint values, converts a Word Node, converts a recovered Error into
RecoveredError even though it is not a written member, and preserves CST Nodes.
Missing values become null. Inferred nil/never use the same conservative
TypedValue mapping: a nil-producing or nonproductive rule can recover to an
Error value. Other permitted dynamic values arriving through
_ must follow the same conversion, rather than be filtered or rejected.
Export such mixed/open aliases conservatively as the complete TypedValue
representation, documenting their written members; a narrower TS union would
promise filtering that the existing Go converter does not perform. Test
recovery and dynamic values explicitly. Canonical declared pure node joins
retain their member mapping plus RecoveredError. Nonmatching nodes become null unless the join has CST members,
in which case Go preserves them as Node. Joins without a canonical declared
node-join recipe use any-conversion, as Go does. No checker or inference rule changes are required.
The declarations of the grammar live in types.ts, a module of its own, and
parser.ts refers to them as Types.<name>. The names of the runtime and of the
generated parser (Parser, Actx, Rule, OperatorsRuleLine, the R0/Q0 families, Node,
Value) therefore cannot collide with a grammar type, and a grammar type keeps its
own name. The few names that types.ts declares itself (Span, TNode,
StringNode, RecoveredError, TypedParseResult, TypedValue and the import
RuntimeNode) are reserved: a grammar type with one of them fails generation with
an error that lists them, rather than being renamed. Preserve original grammar names
in discriminators. Account for legal TS binding names and quote property keys where
needed; never normalize Unicode spelling.
Results, Failure and Recovery
A successful parse returns the mapped value and null error. A successful nil
result returns the selected representation's zero value (normally null).
Ordinary parse, action or depth failure returns null with the original error;
invalid option errors do likewise. Top-level rules cannot produce scalar
results, so numeric zero is a field concern rather than a failed scalar rule
result. After recovery, convert the value and preserve the same SyntaxErrors
object and diagnostics. An Error where a struct/terminal is expected becomes
null; an Error in a join remains RecoveredError; list conversion follows
Go's existing Error/empty-list and element-null rules. Do not infer success
from value !== null: nil-valued rules can succeed.
No existing entry point changes its return shape. parseAST performs one Node
parse followed by conversion.
Ownership and Conversion
Converters receive only the completed internal Node result. Allocate fresh
plain objects and arrays, copy spans and scalar values, and never mutate the
source Nodes or action-visible fields. Retain existing public CST Nodes where
mapping requires them. Returned objects are invocation-owned and cannot be
cleared by parser reuse or another parse. readonly is a TS checking contract,
not a promise of runtime freezing. Internal aliasing/object identity is not a
cross-backend equality promise; semantic values and spans are.
Use an explicit conversion work stack for deep structures and nested lists,
and an invocation-local cache keyed by both Node identity and conversion
representation. Iteration alone does not prevent exponential duplication of a
shared DAG: a fold or LR action can store the same capture into two fields at
each growth step. Cache each (Node, representation) once, with representation
IDs derived from canonical converter recipes. Distinguish struct/terminal,
join/_ and list element conversions: the same Error Node can become null in
one position and RecoveredError in another. Cache null using an explicit
presence check, not a null-as-missing sentinel.
Register allocated object/array shells before scheduling their children, fill all pending work before publishing the result, and reuse cached shells on repeated edges. Equivalent converter recipes may share representation IDs; otherwise their output caches stay separate. Work and storage follow the number of distinct Node/representation pairs and visited edges, not the number of expanded root-to-leaf paths. The internal parse graph must remain unchanged. Repeated references may share a converted readonly object within one result; callers cannot assume every field holds a distinct object. Cross-backend pointer identity is not an equality contract.
An LR/fold result may be much deeper than the parser's call stack. Conversion must not introduce a new unclassified JS stack overflow or reinterpret maxDepth as a value-tree depth limit. Work stack and cache belong to one invocation only, including reentrant calls. No cross-invocation converter/cache pooling. Cache bookkeeping adds memory and lookup costs for tree-shaped inputs, and both the Node graph and converted graph can remain live during conversion; measure this cost as well as the avoided DAG expansion.
Unicode, Positions and Integers
Copy Node text and coordinates unchanged. CodePoints positions count decoded
Unicode scalars; Bytes positions count UTF-8 bytes, never JavaScript UTF-16
indices. String input replaces lone surrogates with U+FFFD as the current
runtime does. Invalid byte input matches as U+FFFD per offending byte, while
Bytes-mode terminal/action text preserves raw invalid byte b as U+DC00+b
(U+DC80–U+DCFF). Conversion must preserve these surrogate escapes rather than
repairing them again. Valid encoded U+FFFD and invalid bytes remain
observable as different text when the current unit requires it.
Keep TS's current signed 64-bit, wrapping integer arithmetic and canonical representation: safe integers are numbers, larger magnitudes are bigint. Copy existing integer field values without Number coercion; do not force all ints to bigint or inherit the executing Go host's native-int width. Positions remain numbers under the existing input-position contract.
Typed plain objects containing bigint are not automatically JSON-compatible:
JSON.stringify can throw, and Bytes-mode escaped text needs explicit handling.
The existing Node marshal remains a Node API. First-stage documentation must
show deliberate exact-integer/text normalization for transport and must not
claim byte-for-byte typed JSON parity. An exact typed serializer can be a
separately scoped API rather than an implicit lossy toJSON hook.
Compatibility and Implementation Sequence
- Implement the public shape and export policy above, deriving a TS mapping from existing type metadata, with deterministic exports and consumer tests.
- Enable the existing generation flag and Go generation option for TS; emit post-parse converters and typed entry points. Keep untyped output and API behavior unchanged. Update CLI validation, guides and support tables.
- Complete differential/ownership/depth gates and measure source/build and conversion costs before claiming readiness.
- Only later consider direct typed matching. Require a TS analogue of Go's
full
runtimeOKguard, including every struct field and field-reading action/predicate. Unsupported layouts must retain the converter path. Preserve action semantics, cuts, memoization, LR growth, Operators Rule attempts, recovery and public ownership. Compare direct output with the converter reference using a static generation control; do not promise eligibility or a speedup before evidence.
Closure/recursive VM/iterative VM and generated Go remain unchanged. Generated TS gains opt-in typed output. No format migration is needed; regeneration is required to receive new exports, and reserved-name cascades can require consumer import/type spelling changes.
Alternatives Considered
- Interfaces over existing Nodes only. Cheap emission but leaves runtime field lookup/casts, and does not produce the Go-style converted values.
- Direct typed matching first. May avoid Node allocations but combines a new public contract with a second value runtime and field-read hazards. Defer until the converter path is an executable reference.
- Classes for all grammar types. Enables instanceof checks but adds runtime declarations, constructors and inheritance policy. Prefer erasable interfaces and plain discriminated values.
- Replace
parsewith typed results under-types. Breaks Node callers. Keep explicit additive typed entry points. - Force integers to number or bigint. Number loses precision; all-bigint changes the established canonical representation. Preserve number|bigint.
Testing
The test suite covers the cases below. Focused checks and full integration validation passed; the results below quantify the opt-in converter costs:
- Strict consumer compilation and execution: exported interfaces, every alias,
union narrowing and nullable results. Include grammar types named like the
runtime and generated declarations (Parser, Actx, Rule, OperatorsRuleLine, Node,
R0), and assert that the reserved names fail generation. Assert imports and
declaration exports of the real
runtime.ts,types.tsandparser.ts, not just generated-file compilation. - Differential conversion versus existing Node results and Go typed mappings on common representable values. Normalize backend shape/64-bit integer transport explicitly; do not confuse serialization equality with value equality. Cover empty/nil lists, optionals, Error in all positions, CST/open values and mixed scalar/node joins with recovered/dynamic values.
- Whole-input failures, action errors, recovery, cuts, LR, Operators Rule, field-reading actions, omitted fields, option getters applied once before parsing, generated and overridden depth limits. Retain results/diagnostics across repeated and reentrant parses and verify source Nodes are unchanged.
- Both units; string and Uint8Array inputs; astral characters, combining text, lone surrogates, encoded U+FFFD, malformed and EOF-truncated UTF-8. Verify terminal text and exact spans, including predicates comparing raw byte text.
- Integer boundaries around ±(2^53−1) and signed 64-bit extrema, overflow, negative division/remainder, optional ints and invalid non-integer values.
- Deep LR/fold/list results that stress conversion independently of parse nesting. Add a fold and an LR growth action that store the same accumulator or capture in two fields repeatedly. Compare small controls with the ordinary converter oracle, then verify large DAG controls visit/allocate according to distinct Node/representation counts without expanding paths exponentially. Assert shared results and representation-specific Error conversion, and compare warmed retained heap and process RSS for shared-DAG and ordinary-tree controls. Isolated instantaneous conversion peak remains unmeasured.
- Test ES2020 tsc consumers and real Node runtime first; run the project's available browser/other-runtime checks before claiming those environments were verified.
The focused test suite and full integration validation passed on Node.js 22.18.0 and TypeScript 5.9.3, Darwin/arm64. The full suite covered the checks listed above, including strict consumers, both position units, recovery, retained ownership, integer and Unicode boundaries, and shared DAGs. The generated Go parsers and TypeScript runtime were unchanged by regeneration.
The typed replay compared 168 results across 23 grammars against generated
Go's Node-conversion path, in both units, with explicit integer, text and CST
normalization. Strict consumers verified imports and declaration exports.
PEGO_TS_TYPED_DIR exported matching typed/untyped sources, strict consumers,
declarations and small/large inputs for field-heavy, capture-free, recovery
and shared-DAG controls. The checked-in bench/ts-typed.mjs harness has SHA-256
ed66f403f3de4e2c5ccddb8605dee8e175002bed1f5c8a356b6edf3a854e44da.
Full root tests, vet, all ten parser-module suites, parser freshness, regeneration and focused source-review checks passed. Strict TypeScript consumers and real Node 22 runtime checks passed. Browser, Deno and Bun were not tested.
Performance and Results
The typed API converts the ordinary Node result after parsing. It is an opt-in compatibility feature, not a runtime optimization: conversion can cost substantially more than returning Nodes, and both representations may coexist.
Measurements used the implementation in this commit on base 1cf6840
(runtime source 73f7c01), Go 1.27.1, Node 22.18.0 and TypeScript 5.9.3 on
Apple M3 Max, Darwin/arm64. Large inputs contain 4,000 field-heavy items,
24,000 capture-free code points, 2,000 good/bad recovery pairs, or 6,000 shared
fold/LR levels. Small inputs are the first exported fixture inputs.
The timing comparisons used five alternating fresh-process pairs. For ordinary
large inputs, each process used three warmups and twelve timed calls; the shared
DAG used one warmup and three timed calls. Small inputs used 1,000 warmups and
10,000 calls. Heap measurements used 20 warmups, one result per process, three
explicit GC cycles per stage, and three pairs. They report post-GC retained
heap for bounded result/helper frames, not peak memory. Process RSS includes
imports, verification and warmup, so it is not a conversion peak. Build timing
used five pairs with OS cache warm and includes TypeScript process startup,
checking, declaration emission and output writes; it is not pure compiler time.
| Large input | Unit | Typed conversion / Node time | Post-GC retained heap, Node → typed |
|---|---|---|---|
| Fields | Code points | 2.98× | 2.95 → 1.40 MB |
| Fields | Bytes | 2.04× | 2.96 → 1.41 MB |
| Capture-free | Code points | 1.02× | approximately unchanged |
| Capture-free | Bytes | 0.98× | approximately unchanged |
| Recovery | Code points | 1.11× | 3.38 → 5.29 MB |
| Recovery | Bytes | 1.09× | 3.43 → 5.33 MB |
| Fold | Code points | 3.53× | 2.86 → 0.50 MB |
| Fold | Bytes | 2.30× | 2.87 → 0.50 MB |
| Left recursion | Code points | 2.25× | 2.25 → 0.51 MB |
| Left recursion | Bytes | 1.97× | 2.25 → 0.52 MB |
These are paired medians, not universal multipliers. The capture-free timing ranges included parity; the recovery, fold and left-recursion examples show conversion cost. Retained heap depends on graph shape: fields and recovery can retain more or less; typed fold and left-recursion results use less warmed heap here. Both Node and typed DAG outputs preserve shared edges. The negative or near-zero capture-free heap deltas are measurement noise, not meaningful savings. Low-warmup DAG heap samples varied substantially; the retained-heap table uses the matched 20-warmup protocol and includes runtime bookkeeping, rather than exact object footprints. After release, deltas from the warmed baseline ranged from about −33 KB to +18 KB. This does not establish a leak or its absence in other uses.
For small inputs, typed conversion took 1.24–1.27× the Node path in the field-heavy CodePoints/Bytes and capture-free CodePoints cases. In an untyped Node control, all four measured timing ranges included parity with the untyped baseline. This separates conversion cost from changes to ordinary Node parsing; it does not establish zero overhead for every grammar.
Generated typed source added 4,987–5,871 bytes and declarations added 1,104–1,682 bytes across the fixtures; parent and current untyped output was byte-identical. Fresh TypeScript build-time ratios were 1.013–1.033×, with all paired ranges including parity. Fresh-process first-import ratios were 1.009–1.033×, with every paired range including parity. They measure native TS loading, erasure and module evaluation inside an already started Node process; OS file caches are uncontrolled. They do not establish process startup, cold-filesystem or universal import costs.
The results describe typed conversion and its retained graph for these workloads. They do not establish lower peak memory, a general speedup, or behavior in untested JavaScript runtimes. Direct typed matching remains a separate design with its own eligibility, ownership and differential gates.
Reproduction
Export the checked fixtures with Node 22.18+ and TypeScript 5.9 on PATH:
typed_fixtures="$(mktemp -d)"
PEGO_TS_TYPED_DIR="$typed_fixtures" go test ./internal/engine \
-run '^TestGeneratedTSTyped(Workloads|SharedDAG)$' -count=1
# Field-heavy large CodePoints: five alternating fresh-process pairs.
for round in 1 2 3 4 5; do
if (( round % 2 )); then modes=(node ast); else modes=(ast node); fi
for mode in "${modes[@]}"; do
env -u NODE_OPTIONS node --expose-gc bench/ts-typed.mjs \
--fixtures "$typed_fixtures" --module "$typed_fixtures/fields/parser.ts" \
--fixture fields --size large --unit codepoints --mode "$mode" \
--variant "${round}-${mode}" --phase time --warmup 3 --count 12 \
--retain 1 --input-kind string --entry-mode direct --revision current
done
done
The harness prints bounded JSON summaries, source/input fingerprints, elapsed
time and process RSS. Select bytes, capture-free, recovery, fold or lr
with the matching module path. Use one warmup and three timed calls for large
fold/LR, and 1,000 warmups/10,000 calls for small cases. For memory, use
--phase memory --warmup 20 --retain 1, and three alternating pairs. Result
creation, observation and release finish in helper frames before GC sampling.
To check inactive typed output, compare Node mode from untyped/parser.ts and
parser.ts, using identical inputs, units and call counts.
Source/declaration checks cover all five fixture modules. Build comparisons
stage typed and untyped modules under the same parser.ts filename, with an
identical bounded Node-only adapter using parse, parseWithOptions and
recognize; the strict flags match runTypedTSFixture and add
--declaration --emitDeclarationOnly. Each observation uses a fresh compiler
process and output directory; five alternating pairs cover fields,
capture-free and fold. Typed-only API declaration checks are separate from
these matched build controls. Keep raw process records local and summarize
paired medians, ranges and uncertainty.
Limitations and Follow-Up Scope
The implemented API is configured-start parsing through parseAST and parseASTWithOptions, following existing TS call conventions. Interfaces are plain discriminated values with readonly grammar fields and lowercase metadata.
Mixed/open aliases are conservative because existing any-conversion semantics can retain recovered and dynamically typed values beyond their written members. CST Node unions require instanceof narrowing. Exact typed serialization, typed named dispatch and direct typed matching are separately scoped follow-ups. The integer/Unicode representation and scalar-rule rejection remain established contracts. Browser, Deno and Bun behavior remains unverified, and the measurements above apply only to the stated workloads and runtime.