Generated TypeScript Parsers
pego gen -lang ts turns a grammar into TypeScript modules: a parser you commit next to your code and import like
any other module. The modules have no dependencies, not even on Node.js: they run in Node.js, Deno, Bun and browsers,
and they return the same trees and the same errors as the engine and the generated Go parsers. The output is a
directory of up to three files: parser.ts (the parser of the grammar), runtime.ts (the runtime, which is the same
for every grammar) and, with -types, types.ts (the types of the grammar).
This guide shows how to generate a parser, how to call it, how it maps the engine's Go types to TypeScript, what is different in JavaScript (strings, ints, the stack), and how to keep it up to date. It assumes you know the Go generator's guide, code generation, only for the comparisons.
- Why generate TypeScript
- Quick start
- The command
- The generated API
- Typed values
- Input, positions and units
- Values of actions
- Errors
- Deep nesting and the JavaScript stack
- What is supported
- Keeping generated code up to date
- Trade-offs
Why Generate TypeScript
Use it when a grammar has to run where Go does not: in a browser (a syntax checker, an editor, a playground), in a
Node.js tool, or in a TypeScript service that shares a format with a Go one. Since both parsers come from the same
.pego file and return identical results, the two sides cannot drift apart: a tree serialized on one side is the tree
the other side would have built.
| Engine (Go) | Generated Go | Generated TypeScript | |
|---|---|---|---|
| Runs in | Go programs | Go programs | Node.js, Deno, Bun, browsers |
| Dependencies | github.com/ornew/pego |
Standard library | None |
| Trees and errors | Reference | Identical | Identical (also as JSON, byte for byte) |
| Recognition (no tree) | RecognizeOnly |
Recognize |
recognize, with -recognize |
Typed values (-types) |
— | Yes | Yes: Node conversion |
| Streaming, incremental, backends | Yes | No | No |
Quick Start
The grammar, pairs.pego, is the one of the Go guide:
type Pair = { Key node[string]; Value node[string] }
@start(default)
rule main = ws ps:pair+ $$ => $ps
rule pair Pair = k:key "=" v:value ";"? ws => Pair{Key: $k, Value: $v}
@start
rule key = [a-zあ-ん]+ #atomic
rule value = [0-9]+ #atomic
rule ws = [ \t\n]*
Generate the module (pego is installed with go install github.com/ornew/pego/cmd/pego@latest, or run as
go run github.com/ornew/pego/cmd/pego):
pego gen -lang ts -g pairs.pego -o pairs
The result is the directory pairs with parser.ts and runtime.ts, each starting with
// Code generated by pego. DO NOT EDIT.. parser.ts imports ./runtime.ts. Use it:
// main.ts
import { parse, parseRule, marshal, Bytes, Node, SyntaxError } from "./pairs/parser.ts";
const { node, error } = parse("abc=12; あい=3");
if (error !== null) {
throw error;
}
console.log(String(node));
for (const pair of node!.children) {
const key = pair!.field("Key") as Node;
console.log(pair!.type, JSON.stringify(key.text), "at", key.start + "-" + key.end);
}
// Positions in UTF-8 bytes.
console.log(parse("あい=3", Bytes).node!.children[0]!.end);
// Any rule marked @start can be the start rule.
const k = parseRule("key", "abc");
console.log(String(k.node), k.error);
console.log(parseRule("nope", "abc").error?.message);
// Syntax errors carry the position and what was expected.
const bad = parse("abc=x");
if (bad.error instanceof SyntaxError) {
console.log(bad.error.line, bad.error.col, bad.error.expected, bad.error.message);
}
// JSON: the same as the engine's, byte for byte with marshal.
console.log(marshal(k.node));
console.log(JSON.stringify(k.node));
node main.ts # Node.js 22.18+, or Deno, Bun, tsx
[Pair{Key: "abc", Value: "12"} Pair{Key: "あい", Value: "3"}]
Pair "abc" at 0-3
Pair "あい" at 8-10
8
"abc" null
rule nope is not defined
1 5 [ '[0-9]' ] 1:5: syntax error: expected [0-9]
{"start":0,"end":3,"value":"abc"}
{"start":0,"end":3,"value":"abc"}
Apart from the formatting of console.log, this is what the Go program of the Go guide
prints for the same grammar and inputs.
The Command
pego gen -lang ts -g <grammar> -o <directory> [-s <rule>] [-runtime embed|import|only] [-runtime-module <specifier>] [-types] [-recognize] [-observability]
| Flag | Default | Meaning |
|---|---|---|
-lang |
go |
ts generates TypeScript |
-g |
(required) | The grammar: .pego or .json |
-s |
the default start rule | The start rule of parse |
-o |
(required) | The output directory (it is created). Generated files that the new run does not write again are removed, except that -runtime import leaves runtime.ts alone; a file that pego did not generate is never removed or overwritten |
-runtime |
embed |
embed writes runtime.ts next to parser.ts; import imports the runtime from -runtime-module and writes no copy; only writes just runtime.ts (no -g) |
-runtime-module |
(required with -runtime import) |
The module specifier parser.ts and types.ts import the runtime from, for example ../shared/runtime.ts. Several parsers share one runtime this way: generate it once with -runtime only |
-types |
off | Write types.ts with the grammar's interfaces and aliases (also exported from parser.ts), and add the configured-start parseAST/parseASTWithOptions |
-recognize |
off | Also generate recognize and recognizeWithOptions, which check input without building a tree |
-observability |
off | Accept the trace and profile options (see debugging); without it they are rejected |
-max-depth |
0 |
Generated nested rule-call default; zero selects 100,000, otherwise a positive safe integer |
-pkg and -nodoc are for Go and are rejected with -lang ts. As for Go, the output is deterministic (generating
twice gives identical files), and grammar errors are reported with positions before any file is written.
The same generator is available from Go, in package github.com/ornew/pego/gen, for build tools:
files, err := gen.TypeScript{}.Generate(g, "main") // grammar, start rule: runtime.ts and parser.ts
files, err = gen.TypeScript{Recognize: true, MaxDepth: 600_000}.Generate(g, "main")
files, err = gen.TypeScript{Runtime: gen.RuntimeImport, RuntimeModule: "../shared/runtime.ts"}.Generate(g, "main")
runtime, err := gen.TypeScript{}.GenerateRuntime() // runtime.ts alone
Types: true enables typed TypeScript declarations and result conversion: gen.TypeScript{Types: true}.Generate(g, "main").
The Generated API
// Parses the whole input with the start rule (or the start rule name; "" selects the default).
export function parse(input: string | Uint8Array, unit?: Unit): ParseResult;
export function parseRule(name: string, input: string | Uint8Array, unit?: Unit): ParseResult;
// With -recognize: checks the input without building a tree; null if it matches.
export function recognize(input: string | Uint8Array, unit?: Unit): Error | null;
export interface ParseOptions {
unit?: Unit;
maxDepth?: number;
signal?: CancellationSignal | null; // with -work-controls only
maxInputWork?: number; // with -work-controls only
maxSteps?: number; // with -work-controls only
}
export function parseWithOptions(input: string | Uint8Array, options?: ParseOptions): ParseResult;
export function parseRuleWithOptions(name: string, input: string | Uint8Array, options?: ParseOptions): ParseResult;
// With -recognize:
export function recognizeWithOptions(input: string | Uint8Array, options?: ParseOptions): Error | null;
export interface ParseResult { node: Node | null; error: Error | null }
export type Unit = 0 | 1;
export const CodePoints: Unit; // the default
export const Bytes: Unit;
export class Node {
type: string; rule: string; start: number; end: number; text: string;
children: readonly (Node | null)[];
fields: readonly NodeField[]; // in the order they were set
field(name: string): Value; // null if absent
isTerminal(): boolean;
toString(): string; // the S-expression form of Node.String, quoted with Go's printable characters
toJSON(): NodeJSON; // for JSON.stringify
}
export interface NodeField { readonly name: string; value: Value }
export type Value = Node | number | bigint | string | boolean | null;
export function marshal(n: Node | null): string; // JSON exactly as encoding/json writes the engine's Node
export class SyntaxError extends Error { pos; line; col; expected: string[]; messages: string[]; reason(): string }
export class SyntaxErrors extends Error { errors: SyntaxError[] }
Compared with the Go API:
- A result object instead of
(node, err).parsedoes not throw for bad input: it returns{ node, error }, with the same combinations as Go: a node and no error; no node and an error; or, after#recover, both a node andSyntaxErrors. It throws only for bugs (an exception that is not a parse error). SyntaxErrorshadows the global of the same name in modules that import it, as in parser generators such as Peggy. Import it under another name if you need both:import { SyntaxError as PegoSyntaxError } from "./pairs/parser.ts". Itsmessageis Go'sError()("1:5: syntax error: expected [0-9]"), andreason()is Go'sMessage().- Field values are
Node, numbers (or bigints, see values), strings, booleans ornull; narrow them withinstanceof Nodeortypeof. - JSON.
marshal(node)gives exactly the bytes Go'sjson.Marshalgives, including its escapes of<,>and&, which is what the tests compare.JSON.stringify(node)gives the same keys in the same order (fields sorted), with JavaScript's escaping, and the same values except in two cases: ints beyond the safe integers become rounded numbers, and inBytesmode an invalid input byte in text is written as the surrogate that stands for it (see units), such as"a\udcff"wheremarshaland the engine write"a�". - Deep trees. Left recursion, left-associative Operators Rule operators and
foldlbuild trees as deep as the input is long, without deep calls.marshalandtoStringwrite trees of any depth (encoding/jsongives up beyond 10,000 levels;marshaldoes not).JSON.stringifyrecurses and throws aRangeErroron Node.js's default stack beyond a few thousand levels (between 3,000 and 5,000 forrule e = e "+" n / n): usemarshalfor such trees. - One parser module per grammar. Every
parser.tsexports the same names, so import two parsers under namespaces:import * as pairs from "./pairs/parser.ts". Theirruntime.tsfiles are identical; generate the runtime once and point each parser at it with-runtime import -runtime-moduleto share it (then aNodefrom one parser is aninstanceof Nodeof the other). - Names of types.
types.tsis a module of its own, so a grammar type may be calledNode,Value,Parseror anything else of the runtime;parser.tsexports the runtime's own declaration under such a name, and the type stays reachable fromtypes.ts. The namesSpan,TNode,StringNode,RecoveredError,TypedParseResult,TypedValueandRuntimeNodeare declared bytypes.tsitself, and the generator rejects a grammar type with one of them. - A parser keeps no state between calls; it is safe to call from several workers.
Running It
The modules use only erasable TypeScript syntax (no enums or namespaces), so runtimes that strip types can run
them. The files import each other with their real names, ./runtime.ts and ./types.ts, so Node.js 22.18 and
later (which strips the types), Deno and Bun run them as they are: node main.ts. For browsers and older Node.js,
compile it with your build. It needs ES2020 (for BigInt) and no DOM or Node.js types.
To type-check a project that contains these files, set allowImportingTsExtensions (with noEmit, or
emitDeclarationOnly). To compile the project to JavaScript with tsc, set rewriteRelativeImportExtensions
instead (TypeScript 5.7 and later): the emitted files import ./runtime.js and ./types.js.
{"compilerOptions": {"target": "es2020", "module": "nodenext", "rewriteRelativeImportExtensions": true, "outDir": "dist"}}
Bundlers (esbuild, Vite, Bun) resolve the .ts specifiers themselves.
The package that contains the files needs "type": "module" in its package.json (or the files need the .mts
extension): under verbatimModuleSyntax, tsc reports error TS1295 for ES syntax in a file it takes for CommonJS,
and Node.js does not run it as a module.
The tests type-check the files of every generated parser together with tsc --strict and also --noUnusedLocals, --noUnusedParameters,
--noUncheckedIndexedAccess, --exactOptionalPropertyTypes and --erasableSyntaxOnly, so it compiles under strict
project settings. Exclude it from linters and formatters: it starts with /* eslint-disable */.
Typed Values
Add -types to generation to write types.ts with a declaration for every grammar struct
and alias (parser.ts exports them too, so it stays the one module to import), plus parseAST and parseASTWithOptions. They parse the
configured start rule once, then convert its completed Node result. parse,
parseRule, recognition and marshal retain their existing APIs. Named typed
parsing is outside this API.
For a variant of the quick-start grammar whose fields are strings, generate and consume typed values:
type Pair = { Key string; Value string }
@start
rule main = ws ps:pair+ $$ => $ps
rule pair Pair = k:key "=" v:value ";"? ws => Pair{Key: text($k), Value: text($v)}
rule key = [a-zあ-ん]+ #atomic
rule value = [0-9]+ #atomic
rule ws = [ \t\n]*
pego gen -lang ts -types -g pairs.pego -o pairs
import { parseAST, parseASTWithOptions, Bytes } from "./pairs/parser.ts";
import type { Pair } from "./pairs/parser.ts";
const { value, error } = parseAST("abc=12; あい=3");
if (error !== null) throw error;
for (const pair of value ?? []) {
const p: Pair | null = pair;
if (p === null) continue;
console.log(p.type, p.Key, p.Value, p.start, p.end);
}
const bytes = parseASTWithOptions("あい=3", { unit: Bytes, maxDepth: 0 });
console.log(bytes.value?.[0]?.end);
Pair abc 12 0 8
Pair あい 3 8 12
8
The result has { value, error }, with value statically inferred from the
start rule. A successful nil result has null value and null error. Failure
returns null value with the original error. Recovery keeps the converted value
and SyntaxErrors; a recovered error in a specific struct field
converts to null, while a declared node join retains a RecoveredError value.
Check error, rather than interpreting null value alone as failure. Options
are read and validated once; zero maxDepth selects the generated default.
Converted values are plain objects with readonly fields, lowercase type
discriminators and start/end spans. Grammar field spelling is unchanged.
String nodes (StringNode, a TNode<string> whose type is "string") have value; recovered errors have text and message.
readonly is a type-checking contract; the objects are not frozen.
| Grammar type | Generated value |
|---|---|
| struct | interface; nullable where an absent or incompatible recovered value becomes nil |
[]T |
readonly array of converted elements, or null; null differs from an empty array |
*T |
nullable target, including nullable scalar fields |
int |
number | bigint, preserving signed 64-bit arithmetic and safe-number canonicalization |
string, bool |
string, boolean; absent nonoptional scalar fields use "" and false (int uses 0) |
| declared aliases | complete target type, preserving optional, list and node representations |
node[T] |
TNode<T'> with T' the TypeScript type of T; text is a StringNode, a TNode<string>, and node[_] is TNode<unknown> |
tuple (A, B, ...) |
readonly [A', B', ...] |
join of declared node types, or of one declared node type and error, named by an alias or written in place |
member interfaces plus RecoveredError and null; node members are TNode values |
join with a member that is not a declared node type, _ |
conservative TypedValue, following Go's any-conversion semantics |
A grammar join type T | U becomes a TypeScript union of the converted members.
Inferred nil/never starts also use TypedValue: recovery can return a converted
Error even when the normal rule produces no value.
The broad TypedValue union covers converted declarations, StringNode,
RecoveredError, Node and action scalars/null. Mixed joins can preserve
recovered or dynamically typed values beyond their written members, so they
are not exported with an unsound narrow type. In dynamically typed conversion,
a list node remains Node; a statically known list becomes an array. A pure node
join with node members can retain unmatched Nodes, as the Go converter does.
Use instanceof Node first when narrowing a union containing Node, whose
type property is a general string. Top-level scalar-producing rules remain
rejected by the existing type checker; scalar fields and aliases are supported.
Grammar names are allocated in sorted order. Every top-level runtime and
generated declaration is reserved, including private Parser, Actx, Rule,
OperatorsRuleLine, generated symbols such as R0, globals used by the runtime, and
new helpers Span, StringNode, RecoveredError, TypedValue, TypedParseResult.
Collisions append underscores until the name is free: grammar Parser exports
Parser_; R0 and R0_ can export R0_ and R0__. Discriminators retain the
original grammar name. Regeneration can therefore change a collision cascade
and require consumer import/type spelling updates. The original grammar spelling is retained.
Conversion uses an explicit work stack and a per-call cache keyed by Node and canonical conversion representation. Deep fold/LR output does not add recursive JS calls, and repeated captures share converted objects instead of duplicating all DAG paths. Different representations of the same Error or list remain separate. Returned values survive subsequent and reentrant parses; no conversion cache is pooled across calls. Nodes retain their existing mutable class representation. Object identity is not promised to match Go pointer identity.
Text and spans copy the Node values exactly: code-point/byte offsets are not
UTF-16 indices, and byte-unit text preserves invalid-byte surrogate escapes.
Large integers remain bigint, so ordinary JSON.stringify can throw. Choose an
explicit transport convention rather than coercing them to Number:
const json = JSON.stringify(value, (_key, v: unknown) =>
typeof v === "bigint" ? { int64: v.toString() } : v,
);
This convention preserves large integer digits but is not the Node marshal
format. If transporting raw byte-unit text, preserve its surrogate
escapes explicitly. For example, in Node.js:
import { Buffer } from "node:buffer";
function byteTextHex(text: string): string {
const bytes: number[] = [];
for (const ch of text) {
const cp = ch.codePointAt(0)!;
if (cp >= 0xdc80 && cp <= 0xdcff) bytes.push(cp & 0xff);
else bytes.push(...Buffer.from(ch, "utf8"));
}
return Buffer.from(bytes).toString("hex");
}
Use this for text from a Bytes parse; CodePoints text is decoded and normalized. There is no typed serializer or direct typed matcher in this implementation. See design 030 for ownership and validation gates.
Input, Positions and Units
JavaScript strings are UTF-16, but positions count Unicode code points (the default) or UTF-8 bytes (Bytes), as in
the engine. The parser decodes the input first, so positions, columns, len and .Start/.End in actions are
the engine's:
- A string is parsed as its UTF-8 encoding would be. A lone surrogate (which UTF-8 cannot encode) becomes U+FFFD,
as
TextEncoderdoes. This includes lone U+DC80–U+DCFF: each replacement occupies one code point or three bytes. Byte-preserving surrogate escapes apply to text from raw byte input, not to caller strings. - A
Uint8Arrayis parsed as UTF-8 bytes, which is what the engine parses. An invalid sequence decodes as U+FFFD, one per byte, exactly as Go decodes it; inBytesmode the positions count the original bytes. A U+FFFD literal matches each such byte in either unit, just as a replacement-character class does; valid U+FFFD instead occupies three bytes inBytesmode. The matched literal's text follows the byte-preserving convention below, including invalid input bytes. - In
Bytesmode, text keeps the bytes, as Go strings do: in node text and intext(...), each invalid byte b is the lone surrogate U+DC00+b (U+DC80–U+DCFF, like Python'ssurrogateescape), solen, comparisons andtoStringsee the bytes the engine sees.marshalwrites such a byte as the character U+FFFD, as Go's JSON does for PEGO's module (depending on the Go release and settings, Go may write the escape\ufffdinstead, the same JSON value). Valid input never contains these surrogates.
Node start and end are positions in the chosen unit, not indexes into the JavaScript string. To slice the input,
use node.text (string nodes) or keep the positions in code points and convert: Array.from(input).slice(n.start, n.end).join("").
Strings in actions behave as Go strings do: + concatenates, < and the other comparisons order by code point (Go's
byte order of UTF-8, not JavaScript's UTF-16 order), and len counts code points or UTF-8 bytes. Expected tokens in
error messages are sorted in the same order.
Values of Actions
Ints in TypeScript actions are signed 64-bit: 9223372036854775807 + 1 wraps around to -9223372036854775808, / truncates
toward zero and % takes the sign of the dividend. An int is a JavaScript number while it is a safe integer (within
±253−1) and a bigint beyond, so equal ints are always ===, and code that reads a field gets a number in
all ordinary cases:
const n = node.field("Count");
if (typeof n === "number" || typeof n === "bigint") { /* an int */ }
marshal writes big ints exactly; JSON.stringify cannot represent them and writes them as (rounded) numbers.
Errors
The errors are those of the engine, with the same messages:
| Error | When | error |
|---|---|---|
| Syntax error | The input does not match | SyntaxError: pos, line, col, expected, messages |
| Recovered errors | #recover skipped parts of the input |
SyntaxErrors (and node is set) |
| Runtime error in an action | e.g. action in main: division by zero |
Error |
| Too deep nesting | more than the configured nested rule-call limit (100,000 by default) | Error: nesting too deep: more than 100000 rule calls |
| JavaScript stack exhausted | before the limit, see below | Error: nesting too deep: the JavaScript stack overflowed at N rule calls |
With the grammar rec.pego of the Go guide, generated with -recognize into the directory recparser:
import { parse, recognize, SyntaxErrors } from "./recparser/parser.ts";
const { node, error } = parse("let a;let 1;let b;");
console.log(String(node));
if (error instanceof SyntaxErrors) {
for (const e of error.errors) {
console.log(e.line, e.col, e.reason());
}
}
console.log(recognize("let a;let 1;let b;")?.message);
console.log(recognize("let a;"));
([("let" " " "a" ";") error("1:11: syntax error: expected [a-z]") ("let" " " "b" ";")])
1 11 syntax error: expected [a-z]
1:11: syntax error: expected [a-z]
null
Cancellation and Work Limits
A parser generated with -work-controls accepts signal, maxInputWork and maxSteps in the options-aware entry points to
stop a parse of input you do not control. Without the option the parser emits no code that charges work and sets no control, its options type has none
of the three, and a call that passes one returns an error. A parser has one form: with the option it is the controlled one, and a
parse that sets no limit runs the same code, so generate two parsers if you need the plain form as well. signal is any object with aborted and an optional reason, so an AbortSignal fits; the limits are
nonnegative safe integers, and zero means unlimited. An interrupted parse returns no tree and a
ParseInterruptionError whose kind says what happened:
const result = parseWithOptions(input, { maxSteps: 1_000_000, signal: AbortSignal.timeout(2000) });
if (result.error instanceof ParseInterruptionError) {
console.log(result.error.kind, result.error.used);
}
A parse is synchronous and does not yield to the event loop, so a timer such as AbortSignal.timeout cannot fire during
the call: use maxSteps and maxInputWork for a bound that holds, and a signal that is already aborted or a worker
that you terminate for cancellation. The units of the limits are explained in
Cancellation and Work Limits.
Deep Nesting and the JavaScript Stack
The generated parser calls rules recursively, like the generated Go parser, and defaults to the same limit of 100,000 nested
rule calls. -max-depth / gen.TypeScript.MaxDepth set a generated default; parseWithOptions,
parseRuleWithOptions and recognizeWithOptions accept maxDepth for one invocation. Omitted or zero selects the
generated default; a positive safe integer overrides it. Negative values, fractions, NaN, infinity and unsafe
integers return a configuration error through the normal result, before matching or named-rule lookup. Options are
resolved to scalar fields immediately, so later mutation of the caller's object cannot change an in-progress parse.
The existing parse, parseRule and recognize unit signatures keep their behavior. For example:
const result = parseWithOptions(input, { unit: Bytes, maxDepth: 600_000 });
A larger limit does not guarantee host-stack capacity. A a JavaScript stack is far smaller than a goroutine's: Node.js's default stack (about 1 MB) holds some
hundreds to a few thousand nested rule calls, depending on the grammar and on how warm the code is. Measured on
Node.js 24: rule n = "(" n ")" / "x" parses about 1,100 levels of parentheses, and the JSON grammar about 390 levels of
nested arrays (each level is several rule calls). Beyond that, the parse returns an error instead of crashing:
nesting too deep: the JavaScript stack overflowed at 2292 rule calls
That is the way in which a TypeScript parser differs from the engine in practice: input the engine accepts can exhaust a small stack (the design record lists the others, which only input that is not valid UTF-8 can show). To accept deeper input, give the parser a bigger stack. Each nested rule call takes about 1 KB:
-
In a worker thread, set the stack size. With 128 MB, the parentheses grammar reaches the limit of 100,000:
import { readFileSync } from "node:fs"; import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; import { parse, marshal } from "./parser.ts"; if (isMainThread) { const input = readFileSync(process.argv[2]!, "utf8"); const w = new Worker(new URL(import.meta.url), { workerData: input, resourceLimits: { stackSizeMb: 128 } }); w.on("message", (json: string) => console.log(json)); } else { const r = parse(workerData as string); parentPort!.postMessage(r.error === null ? marshal(r.node) : r.error.message); }Nodes are class instances, which do not cross threads intact: send JSON (as here) or do the work in the worker.
-
On the main thread,
node --stack-size=<KB>raises V8's limit, but not the operating system's limit on the thread's stack (typically 8 MB); beyond that the process crashes. Stay below it (--stack-size=7000reaches about 15,000 rule calls).
The parity tests parse in a worker with a 1 GB stack, so that they also check the engine's error exactly at the limit.
What Is Supported
Generated TypeScript supports every feature of the grammar language: Rule Expressions, Operators Rules, left recursion, Predicates and variables, actions, built-in functions, @error and
#recover. It adds parseRule for another start rule, a unit argument for the position unit, recognize (with
-recognize) for matching without a tree, and maxDepth, and with -work-controls signal, maxInputWork and maxSteps, in the options-aware
entry points. It has no #stream and ParseStream, and no Document: #stream is an ordinary repetition, as in Go.
Typed values (-types) give interfaces and aliases and parseAST/parseASTWithOptions for the configured start rule,
while parse still returns Nodes. The specification has
the complete table of what each target supports.
Keeping Generated Code Up to Date
As with Go, generated code is source: commit it, and regenerate it when the grammar changes or you upgrade PEGO (the
embedded runtime comes from the version of pego that generated it). In a Go module, a go:generate line next to
the grammar keeps both parsers in step:
//go:generate go tool pego gen -g pairs.pego -module example.com/pairsparser -o pairsparser
//go:generate go tool pego gen -lang ts -g pairs.pego -o web/src/pairs
In a JavaScript project, a script does the same:
{ "scripts": { "generate": "go run github.com/ornew/pego/cmd/pego@latest gen -lang ts -g pairs.pego -o src/pairs" } }
(replace latest with the version you use, so that everybody generates the same file). To check in CI that the file is current, regenerate and compare: npm run generate && git diff --exit-code src/pairs.
To test that the TypeScript parser agrees with the engine for your inputs, compare its tree with the JSON that
pego parse prints (indented, so compare the parsed values):
pego parse -g pairs.pego -i 'abc=12; あい=3' > want.json
node --input-type=module -e '
import { readFileSync } from "node:fs";
import { isDeepStrictEqual } from "node:util";
import { parse, marshal } from "./pairs/parser.ts";
const want = JSON.parse(readFileSync("want.json", "utf8"));
const got = JSON.parse(marshal(parse("abc=12; あい=3").node));
console.log(isDeepStrictEqual(want, got) ? "same" : "different");
'
PEGO's own tests do this for every grammar in examples/ and the engine's test grammars, in both units, with the
inputs as strings and as bytes (design record 013).
Large character classes, including negated classes and ranges, are supported in strict TypeScript builds.
Trade-Offs
- Size.
runtime.tsis 143,536 bytes (about 4,400 lines, with a 3 KB table of printable characters) and is the same for every grammar;parser.tsgrows with the grammar. The JSON grammar (parsers/json) generates a 46,364-byteparser.ts(55,131 bytes with-types, plus a 2,203-bytetypes.ts) and the TypeScript grammar (parsers/typescript) 2,158,721 bytes. Minified and compressed by a bundler, it is a fraction of that. - Speed. On Node.js 24 (Apple M-series), the JSON grammar parses a 260 KB document in about 18 ms in code points
and 25 ms in bytes, once the code is warm. That is fine for editors and tools, but no match for
JSON.parse: use a generated parser for formats that have no native parser. - Stack. Deep nesting needs a bigger stack than JavaScript gives by default (see above).
- Typed conversion costs. With
-types,parseASTbuilds the Node result and then converts it. Both graphs can remain live during conversion, and per-call caching adds bookkeeping. Shared fold/LR subgraphs stay shared; ordinary tree workloads still pay conversion allocation and lookup costs. This API does not imply a parsing speedup.