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
getStartandgetEnd) 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.tsandconformance/node/nodeModulesTripleSlashReferenceModeOverrideModeError.ts: the compiler reports diagnostics for triple-slash directives (twoamd-modulenames, an unterminatedreference path, aresolution-modethat is notrequireorimport) 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 (onlyconstructor,"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:
TestTypeScriptLibcompares 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.TestTSCMutationsmutates 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 escapedconstructor, 1 the JSX closing tag, 3 anawaitafter 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-levelawaitin its reparse of the module, the parser reports it), and one each ofinterface I { [a, yield b]: number },create<readonly>(x),typeof M.<T,>,import< <Promise<any>>("m")and an identifier escape afterthis(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