PEGO

TypeScript

github.com/ornew/pego/parsers/typescript parses TypeScript 5.9 and TSX as the parser of the TypeScript 5.9.3 compiler does, with a parser generated by PEGO from typescript.pego, a port of the compiler's src/compiler/parser.ts function by function. The AST is the compiler's: the types are named after its SyntaxKinds, their fields are in the order of its forEachChild, and the ranges are those of its getStart and getEnd. It depends only on the standard library.

go get github.com/ornew/pego/parsers/typescript

Use

// Typed values with positions: a SourceFile of Statements, each node with its Span in the input.
f, err := typescript.ParseAST("export const answer: number = 6 * 7;")
decl := f.Statements[0].(*typescript.VariableStatement).DeclarationList.Declarations[0]
fmt.Println(typescript.AsNode(decl.Initializer).Range()) // 30 35: the BinaryExpression 6 * 7

// .tsx files, or by the name of the file, as ts.createSourceFile chooses.
f, err = typescript.ParseTSX("const el = <ul className=\"list\">{items.map((i) => <li key={i}>{i}</li>)}</ul>;")
f, err = typescript.ParseFile("main.mts", src) // by the name: "x.tsx" is TSX; a module's top level may await

// Walk the tree by kind, in the order of forEachChild.
f, err = typescript.ParseAST("fetch(url).then(log)")
typescript.Inspect(f, func(n typescript.ASTNode) bool {
	if call, ok := n.(*typescript.CallExpression); ok {
		fmt.Println(typescript.Kind(call.Expression), call.Start, call.End)
	}
	return true
})
// PropertyAccessExpression 0 20
// Identifier 0 10

// Syntax errors, and only checking.
_, err = typescript.ParseAST("const a = 1;\nconst b = ;")
var se *typescript.SyntaxError
if errors.As(err, &se) {
	fmt.Println(se.Line, se.Col, se.Message()) // 2 11 expected an expression
}
err = typescript.Recognize("interface A { x: number }") // nil
ParseAST(input, unit...) (*SourceFile, error) A .ts file as typed values: Statements and EndOfFileToken, every node a pointer to a struct or terminal type of this package, with its Span (Start, End)
ParseTSX(input, unit...), RecognizeTSX(input, unit...) The same for a .tsx file, where <T>x is not a type assertion and < starts JSX
ParseFile(name, input, unit...) By the name of the file (IsTSX); a module (see IsExternalModule) that may contain await at its top level is read in the await context, as the compiler reparses it, except .d.ts files
Recognize(input, unit...) error Only checks a .ts source, without building anything
Parse(input, unit...) (*Node, error) The tree of *Node, as the engine returns it; a .tsx source is parsed by prefixing U+0000 (the positions are then one more)
Kind(node) string The SyntaxKind name of a node ("CallExpression", "PlusToken", "ExportKeyword", "ThisKeyword")
ForEachChild(node, f), Inspect(node, f) The children of a node in the order of ts.forEachChild, and the depth-first walk (f(nil) after the children)
AsNode(v) ASTNode A value of a union field (Expression, Statement, TypeNode, BindingName, ...) as a node, with Range()
IsExternalModule(f), IsTSX(name) As ts.isExternalModule's probable-module test and the compiler's choice of JSX by file name
(*StringLiteral).Value(), (*NoSubstitutionTemplateLiteral).Value() The string with its escapes decoded (an unpaired surrogate gives U+FFFD)
(*NumericLiteral).Value(), (*BigIntLiteral).Value() The number as a float64 (+Inf if too large) and the integer as a *big.Int, with 0x, 0b, 0o and _
(*Identifier).Value(), (*PrivateIdentifier).Value() The name with its unicode escapes decoded
*SyntaxError, SyntaxErrors Line, Col, Pos and the expected tokens (or Message()) of the first syntax error, after which parsing stops

Positions are in code points by default; ParseAST(src, typescript.Bytes) counts bytes. A node's Span covers its tokens without the white space and comments before it, and the tree has the nodes the compiler's forEachChild visits: modifiers, decorators and type parameters are lists, tokens such as ?, !, ... and => are nodes (only where the compiler keeps them), and Identifier, Token, Modifier, KeywordTypeNode and the literals hold their Text. The fields of union types have interface types (Expression, TypeNode, ...); use a type switch, or AsNode to read the range and Kind for the kind of the compiler's node.

Conformance

The parser is checked against the compiler itself, with differential tests. Each test case of tests/cases/compiler and tests/cases/conformance of the TypeScript repository (tag v5.9.3) is split into the files its // @filename directives name, as the compiler's test harness does, and each .ts, .mts, .cts and .tsx file is parsed by the compiler (ts.createSourceFile, run with node) and by ParseFile. The file agrees if

  • the compiler reports no parse diagnostic and the parser accepts the file, and the kinds, ranges (UTF-16 offsets of getStart and getEnd) and numbers of children of all nodes of the two trees are equal, or
  • the compiler reports parse diagnostics and the parser rejects the file (the positions of the errors are not compared).

14,799 of 14,804 files agree, 14,043 with the same tree and 756 rejected by both:

Directory of tests/cases Files Agree of which rejected by both Differ Skipped
compiler 7884 7882 195 2 728
conformance (the files directly in it) 2 2 0 0 0
conformance/Symbols 8 8 0 0 0
conformance/additionalChecks 1 1 0 0 0
conformance/ambient 38 38 2 0 0
conformance/async 188 188 26 0 2
conformance/asyncGenerators 3 3 0 0 0
conformance/classes 526 525 21 1 9
conformance/constEnums 11 11 0 0 0
conformance/controlFlow 55 55 0 0 1
conformance/declarationEmit 45 45 0 0 9
conformance/decorators 96 96 11 0 0
conformance/directives 5 5 0 0 2
conformance/dynamicImport 139 139 2 0 0
conformance/emitter 91 91 0 0 0
conformance/enums 14 14 1 0 0
conformance/es2016 1 1 0 0 0
conformance/es2017 12 12 0 0 0
conformance/es2018 4 4 1 0 0
conformance/es2019 14 14 0 0 3
conformance/es2020 28 28 0 0 0
conformance/es2021 12 12 0 0 0
conformance/es2022 21 21 9 0 0
conformance/es2023 2 2 0 0 0
conformance/es2024 3 3 0 0 0
conformance/es5 1 1 0 0 0
conformance/es6 1110 1110 64 0 5
conformance/es7 45 45 7 0 0
conformance/esDecorators 166 166 4 0 8
conformance/esnext 2 2 0 0 0
conformance/expressions 382 382 22 0 2
conformance/externalModules 527 527 9 0 16
conformance/functions 18 18 0 0 0
conformance/generators 15 15 0 0 0
conformance/importAssertion 12 12 2 0 0
conformance/importAttributes 20 20 3 0 1
conformance/importDefer 32 32 4 0 0
conformance/interfaces 66 66 2 0 0
conformance/internalModules 105 105 0 0 0
conformance/jsdoc 82 82 1 0 419
conformance/jsx 311 310 58 1 1
conformance/moduleResolution 135 135 0 0 91
conformance/node 297 296 6 1 222
conformance/nonjsExtensions 22 22 0 0 4
conformance/override 27 27 0 0 4
conformance/parser 1029 1029 269 0 10
conformance/pedantic 2 2 0 0 0
conformance/references 38 38 0 0 6
conformance/salsa 45 45 0 0 265
conformance/scanner 35 35 18 0 1
conformance/statements 207 207 6 0 0
conformance/types 853 853 13 0 6
conformance/typings 17 17 0 0 13
total 14804 14799 756 5 1828

(The test cases hold 1,828 more files that are not .ts, .mts, .cts or .tsx, such as .js and .json; they are not compared.)

The five files that differ are listed in testdata/tsc-known-failures.list; the test fails on any other difference and on a listed file that no longer differs:

  • compiler/amdModuleName2.ts, compiler/invalidReferenceSyntax1.ts and conformance/node/nodeModulesTripleSlashReferenceModeOverrideModeError.ts: the compiler reports diagnostics for triple-slash directives (two amd-module names, an unterminated reference path, a resolution-mode that is not require or import) while it processes pragmas. They are not syntax, and the parser accepts the files.
  • conformance/classes/constructorDeclarations/quotedConstructors.ts: "\x63onstructor"() {} is the constructor of the class for the compiler, which compares the text of the name after decoding it; the parser reads a method (only constructor, "constructor" and 'constructor' spelled out are constructors).
  • conformance/jsx/tsxOpeningClosingNames.tsx: the closing tag </A . B . C.D> has white space between the parts of its name, which the compiler accepts and the parser rejects.

Other tests, which need the compiler too:

  • TestTypeScriptLib compares the trees of the 102 declaration files of the typescript package (lib/*.d.ts, from 1 KB to 1.8 MB), all of which agree.
  • TestTSCMutations mutates test cases (a token deleted, duplicated, replaced by one of 98 tokens where the grammar decides, inserted, or swapped with the next) with a seeded generator, and compares acceptance and trees. On 150,000 mutations (seeds 1 to 5, 30,000 each) 32 differ: 17 are triple-slash pragmas, 6 the escaped constructor, 1 the JSX closing tag, 3 an await after the name of a class or a namespace in a module (export class B await {}: the compiler drops the diagnostic at the start of a statement that holds a top-level await in its reparse of the module, the parser reports it), and one each of interface I { [a, yield b]: number }, create<readonly>(x), typeof M.<T,>, import< <Promise<any>>("m") and an identifier escape after this (this\u{abcd}), which the compiler rejects and the parser accepts or the other way round.

The tests that need no external tool: TestGolden (the trees of testdata/*.txt, also checked on every backend of the engine by go test ./parsers), TestValid and TestInvalid (sources the parser must accept and reject), TestSpans, TestUnits, TestParseFile, TestErrors, TestKind, the Value tests and FuzzParse (go test -fuzz FuzzParse: ParseAST and Recognize agree on any input, and the ranges of a parsed file are valid; 2.8 million executions in 60 seconds without a failure).

Running the differential tests

They are skipped unless two environment variables are set:

# The compiler: node and the typescript package, version 5.9.3
mkdir /tmp/ts && cd /tmp/ts && npm install typescript@5.9.3
# The test cases of the TypeScript repository at the same version
git clone --depth 1 --branch v5.9.3 https://github.com/microsoft/TypeScript /tmp/TypeScript

cd parsers/typescript
export PEGO_TYPESCRIPT=/tmp/ts/node_modules/typescript
export PEGO_TYPESCRIPT_TESTS=/tmp/TypeScript/tests/cases
go test -run 'TestTypeScriptSuite|TestTypeScriptLib|TestTSCMutations|TestTSCSnippets' -v .   # the table above is its output
go test -run TestTypeScriptSuite -tsc.v .                      # every difference, with the text around it
go test -run TestTypeScriptSuite -tsc.run 'classes/.*' .       # only the test cases whose path matches
go test -run TestTSCMutations -tsc.mutations 30000 -tsc.seed 2 .
go test -run TestTypeScriptSuite -tsc.update .                 # rewrite testdata/tsc-known-failures.list

testdata/tsc.js is the node program the tests talk to: it parses a source and writes the compiler's parse diagnostics and its tree.

Grammar

typescript.pego has one rule or a few for each parse... function of the compiler's parser, with its name in a comment where it helps. The compiler's context flags (yield, await, in disallowed, decorator, no return type on arrow functions, conditional types disallowed, JSX) are bits of one variable, ctx, which rules test with predicates; one variable rather than seven because rules that read variables are memoized per value. The start rule main parses a .tsx file when the input starts with U+0000, which no TypeScript file can start with, and ParseTSX adds it and moves the positions back, so one generated parser (and one set of AST types) serves both; U+0001 after it reads the top level in the await context, which ParseFile does for modules.

Whitespace is skipped by the callers, not by the rules, so that every node spans exactly its tokens (ws skips white space and comments, sp the same without crossing a line break; where the compiler checks scanner.hasPrecedingLineBreak() the grammar uses sp, or nlAhead). The grammar is as lenient as the compiler's parser (see the package documentation), and the other differences from it are the five above.

Performance

ParseAST on files of 220 KB to 6 MB, on an Apple M3 Max (go test -bench ., the fastest of five runs of ten iterations; the machine was not idle, so the figures are conservative), next to the compiler's parser (ts.createSourceFile on the same file in node 24 after a warm-up, with node testdata/tsc-bench.js $PEGO_TYPESCRIPT file..., which is not part of the Go tests):

Source Size ParseAST Throughput Allocations Parse Recognize the compiler's parser
synthetic code (generated by the benchmark) 263 KB 46.0 ms 5.7 MB/s 172,000 78 ms 75 ms 10.4 ms
lib.es5.d.ts 218 KB 6.1 ms 36 MB/s 12,000 11 ms 11 ms 4.0 ms
lib.dom.d.ts 1.9 MB 58 ms 32 MB/s 105,000 114 ms 111 ms 30 ms
typescript.d.ts 588 KB 24.8 ms 24 MB/s 57,000 50 ms 49 ms 10.2 ms
lib/_tsc.js, the compiler's own bundle (code) 6.2 MB 524 ms 11.9 MB/s 1,630,000 821 ms 826 ms 167 ms

The benchmarks of the synthetic source need nothing; the others read the typescript package from PEGO_TYPESCRIPT and are left out without it. The parser is 1.5 to 2.5 times slower than the compiler's hand-written parser on declaration files and 3 to 4.5 times on code (the synthetic source, dense in generics and types, is the slowest per byte). Recognize builds nothing but is slower than ParseAST, whose rules the generator compiles into direct code (see the code generation guide); Parse builds the generic tree.

The first version of the grammar, which followed the compiler's order of alternatives, took about 110 ms, 10 ms, 110 ms, 60 ms and 1.55 s for ParseAST on these files. The optimizations are commented in the grammar; what paid was testing the next character before trying a rule that fails at once and records what it expected (the operators of the Pratt loops, whitespace, modifiers, arrow functions), dispatching on the first character or word (primary expressions, types, statements), and parsing a union, intersection or conditional type or expression once instead of parsing its first operand, failing, and parsing it again from the memo. The number of rule calls on 300 KB of the bundle fell from 1.66 million to 0.54 million.

Development

go generate ./parsers                 # regenerate parser.go after changing typescript.pego (from the repository root)
go test ./parsers                     # parser.go up to date; golden files on every backend of the engine
cd parsers/typescript && go test ./...   # the tests that need no compiler, and the differential tests if the variables are set
go test -fuzz FuzzParse               # fuzz the parser
go test -bench .                      # benchmarks