Skip to content

Run the JavaScript parser on TypeScript 7 - #8690

Draft
knutwannheden wants to merge 13 commits into
mainfrom
ts7-prep-token-navigation
Draft

Run the JavaScript parser on TypeScript 7#8690
knutwannheden wants to merge 13 commits into
mainfrom
ts7-prep-token-navigation

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Stacked on #8689, which moves this module to TypeScript 6 — review that first.

The parser runs on TypeScript 7. The full suite passes at 2035 tests, the same count as on 6, with no test disabled or removed.

TypeScript 7.0 ships no compiler API in the sense 6 did: typescript's main entry is lib/version.cjs, which exports a version string, and the AST, checker and program construction live behind unstable/* subpath exports that the 7.1 iteration plan does not schedule for stabilisation. Waiting for 7.1 would not change that — 7.1 stabilises the Content Mapper, Emit and Language Service APIs, none of which are what a parser reads. So this builds on unstable/* and pins the deviations with a conformance test that compares the two compilers directly.

The seam

JavaScriptParser reached token-level navigation — getChildren, getChildCount, getChildAt, getFirstToken, getLastToken — at 110 sites across 57 functions. Those five surface the punctuation and keyword tokens that node fields omit, which is where whitespace placement gets its offsets, and TypeScript 7 exposes none of them: the AST is deserialised from the compiler process, which never materialises token-level children.

The first commit moves those sites behind named helpers, still delegating to the native methods, so it is behaviour-preserving. The rewrite was mechanical and checked as such: inverting it programmatically reproduces the previous source byte-for-byte in parser-utils.ts, and in parser.ts everywhere except one site, where node.elseStatement?.getChildAt(...) becomes !!node.elseStatement && childAt(node.elseStatement, ...). Both short-circuit before evaluating the argument.

compiler.ts is then the single place the parser binds to a compiler release. None of the 1048 ts.* references change: the namespace carries its types as well as its values, so re-exporting it leaves every ts.SyntaxKind and ts.Node resolving as before, and the release-specific renaming lives in one file.

Reading the TypeScript 7 tree

ts7/token-navigation.ts rebuilds the five missing calls from forEachChild (a Node method in 7, not a free function), createScanner, and the source text. Across the parser's own sources the reconstructed token stream is identical to TypeScript 6's native one — kinds, order and offsets — once two equivalences hold, both recorded in the test rather than skipped:

  • EndOfFileToken is renamed EndOfFile.
  • An elision in a binding pattern (const [a, , b] = …) is a zero-width BindingElement where 6 emits an OmittedExpression. The equivalence is conditional on zero width.

Four details had to be right for the offsets to line up, and the conformance test found each of them:

  • A synthetic token's pos is its full start, so getTokenFullStart() is the source rather than getTokenStart().
  • A SyntaxList is re-scanned rather than yielding its raw elements, or the leading | of | "a" | "b" disappears.
  • The SyntaxList wrapper is tagged with a Symbol; tagging it with an elements field silently captured NamedImports and ArrayLiteralExpression, whose braces and brackets then vanished.
  • Trivia is whitespace and comments, so a token's start comes from skipTrivia. Searching for whitespace alone left getStart() equal to getFullStart() ahead of a comment, and every comment before a token was dropped.

Children are built once per node, because callers locate a node among its siblings by identity.

Program construction and the checker

ts7/program.ts replaces createProgram and the CompilerHost, neither of which exists in 7. The compiler owns parsing and module resolution, so a program is described to it: in-memory sources are served over filesystem callbacks, and returning undefined defers to the real filesystem. resolveModuleNameLiterals, which parser.ts overrode because its sources are not on disk, has no counterpart to port — the model removes the need.

Starting a compiler and loading its libraries costs 59ms against the 0.09ms a parse takes, so the process lives as long as the parser and a project is opened per batch. Reusing it means a path can be parsed twice with different text and the compiler answers with what it read first, so each batch announces its sources as changed.

ts7/checker.ts covers what moving the checker into that process changes. An identifier that names a type resolves to any through getTypeAtLocation — the name references a type rather than being an expression with one — and the type comes from getTypeFromTypeNode on the type node containing it. Routing on that takes agreement with TypeScript 6 from 46/57 identifiers to all of them across a fixture covering the constructs type-mapping.ts branches on. Symbol flags agreed for every identifier before and after, which matters more, since flags are what the mapping branches on.

Two of those now agree by being more specific: routing through the type node gives Base<string> where 6 gives typeof Base, and Circle[] where 6 gives T[]. Both name the same reference and the instantiated form is the one the LST wants.

Deviations worth knowing

JSDoc never entered the tree before, because the parser sets jsDocParsingMode: ParseNone. TypeScript 7 offers no equivalent and reparses JSDoc types into the AST as nodes of the declaration they annotate, positioned inside the comment and flagged Reparsed. Read as syntax they print as annotations the source never had, so they are excluded from a node's children and from the type information the LST carries. It also reports syntax errors from inside JSDoc, so a diagnostic whose position falls in a comment is not treated as a parse failure.

(symbol as any).parent returned a private numeric handle rather than a symbol, which the compiler rejected at runtime as an empty symbol handle. getParent() is the accessor, and this is the shape of failure the internal-API casts were always going to have: they type-check through as any and break only when run.

A class exported as the default carries default as its symbol name rather than the name it was written with, which is only on the declaration, so qualified names are built from there.

Filed upstream

Six behaviours are gaps or bugs rather than deliberate changes, each verified against 7.0.2 and 7.1.0-dev.20260827.1:

microsoft/TypeScript#63861, which left NodeArray.hasTrailingComma unset, is fixed in 7.1-dev but not in 7.0.2, so trailing commas are read off the source here.

Build and tests

Cold builds are faster; the suite is not yet:

compiler cold build full suite
before #8689 5.9.3 3.9s 77.0s
#8689 6.0.3 4.6s 52.9s
this PR 7.0.2 1.2s 126.0s

The suite gap is one compiler process per parser instance, at 60.5ms against 1.5ms for a reused one, across roughly two thousand instances. A compiler shared between parsers would close it, and is left out of this PR because its lifetime is a design question rather than a detail.

Scope

module: node20 is required: TypeScript 7 is ESM-only and this package is CommonJS, so node16 reports TS1479 on every unstable/* import. That needs Node 22.12, which engines and the module's CLAUDE.md now state.

The 44 visitJSDoc* methods and the ones for Bundle and CommaListExpression are removed. The dispatch map is built from the live SyntaxKind, so kinds the compiler no longer produces were unreachable.

TypeScript 7.0 ships without a compiler API, so 6.0 is the last release
carrying the classic surface this module parses against.

Three 6.0 behaviour changes needed handling:

- `types` defaults to `[]`, which silently emptied type attribution for
  ambient `@types/*` packages; `types: ["*"]` restores enumeration.
- `strict` defaults to true, adding a `null` constituent to every nullable
  union; it is now set explicitly, since strictness belongs to the parsed
  project rather than to the compiler this module pins.
- Diagnostics 1212 and 1540 are new and non-fatal; the AST is unchanged in
  both cases, so they join `excludedCodes`.

Also drops the deprecated `baseUrl` from the test and fixtures tsconfigs.
`getChildren`, `getChildCount`, `getChildAt`, `getFirstToken` and
`getLastToken` surface the punctuation and keyword tokens that node fields
omit, and the parser depends on them at 110 sites across 57 functions for
the offsets its whitespace placement needs.

TypeScript 7 exposes none of the five: its AST is deserialised from the Go
compiler, which never materialises token-level children. They are
reconstructible from `forEachChild`, `createScanner` and `node.jsDoc`, all
of which 7 does export -- a prototype of that reconstruction matches the
native implementation on 409699 nodes across 357 files, exactly, for all
five entry points.

The call sites move behind named helpers so that reconstruction has one
place to land. The helpers delegate to the native methods, so behaviour is
unchanged; inverting the rewrite mechanically reproduces the previous source
except at the one optional-chain site, where `?.` becomes an equivalent
`&&` guard.

Also drops three untyped `getBaseTypes` calls, which are public API.
TypeScript 7 ships no compiler API: `typescript`'s main entry is a version
string, and the AST, checker and program live behind `unstable/*` subpath
exports. This adds the two pieces the parser will need there, each covered by
a conformance test against the TypeScript 6 tree it has to reproduce.

`ts7/token-navigation.ts` rebuilds getChildren/getChildAt/getChildCount/
getFirstToken/getLastToken, which 7 does not expose, from forEachChild,
createScanner and the source text. Across the parser's own 26 sources the
reconstructed token stream is identical to TypeScript 6's native one --
kinds, order and offsets -- once two equivalences are accounted for:
`EndOfFileToken` is renamed `EndOfFile`, and an elision in a binding pattern
is a zero-width `BindingElement` rather than an `OmittedExpression`.

`ts7/program.ts` replaces createProgram and the CompilerHost. The Go process
owns parsing and resolution, so in-memory sources are served over filesystem
callbacks and everything else defers to the real filesystem; node_modules and
the bundled lib files resolve without a module-resolution hook.

The compiler is on `module: node20`, since 7 is ESM-only and this package is
CommonJS. `node16` reports TS1479 on every `unstable/*` import; `node20`
permits require(esm), which Node supports from 22.12, and leaves the emitted
output CommonJS. That floor is declared in `engines.node`; with the ts7
modules kept out of the build the emit is byte-identical to node16, so it
constrains building and testing rather than consumers of the package.

The ts7 modules stay out of the published package, since they run against
`typescript7`, a devDependency, and only the conformance test loads them.

TypeScript 6 stays the compiler the parser runs on. The visitor cannot span
both, because kind numbers differ and it compares against named members of
one enum.
@knutwannheden knutwannheden changed the title Route TypeScript token navigation through one module Reach the TypeScript 7 AST and program through unstable/ast Aug 28, 2026
The checker answers over IPC from the Go process, which changes three things
for the parser.

An identifier that names a type resolves to `any` through
`getTypeAtLocation`, since the name references a type rather than being an
expression with one; the type comes from `getTypeFromTypeNode` on the type
node containing it. `typeAtLocation` routes on that, which takes the
identifiers in a fixture covering the constructs type-mapping.ts branches on
from 46/57 agreeing with TypeScript 6 to all of them. Symbol flags already
agreed for every identifier, which is what the mapping actually branches on.

Two of those now agree by being more specific: routing through the type node
gives `Base<string>` where 6 gives `typeof Base`, and `Circle[]` where 6
gives `T[]`. Both name the same reference and the instantiated form is what
the LST wants, so the test records the pairs rather than hiding them.

A symbol's declarations arrive as handles into the project's tree, so
`declarationsOf` and `valueDeclarationOf` resolve them. `getAmbientModules`,
`getFullyQualifiedName` and `signatureToString` have no counterpart, and are
substituted here.
The parser reads kinds, node types and type guards from `typescript` in
seven files. TypeScript 7 spells some of them differently and serves them
from `unstable/ast` and `unstable/sync` rather than one entry point, so the
binding moves to `compiler.ts` and the seven files import from there.

None of the 1048 references change: TypeScript's namespace carries its types
as well as its values, so re-exporting it as the default leaves every
`ts.SyntaxKind` and `ts.Node` resolving as before.

Pointing the module at TypeScript 7 now reports 184 errors, which is what the
switch costs. They gather into renamed guards, program construction, the
symbol and signature accessors, and the four known field renames.
@knutwannheden
knutwannheden marked this pull request as draft August 28, 2026 04:50
`compiler.ts` points at TypeScript 7 and the dependency moves, so the parser
builds against `unstable/ast` and `unstable/sync` rather than a single entry
point. Token navigation runs on the reconstruction, which returns the scanned
tokens as nodes so the parser's call sites are unchanged.

Beyond renames, the switch touched:

- `parse` and `parseOnly` open a session instead of building a program. The
  compiler owns resolution, so the host and its `resolveModuleNameLiterals`
  override are gone, and paths are absolute throughout since that is how the
  compiler reads them.
- Options take the shape a tsconfig holds. The API's `CompilerOptions` names
  `JsxEmit` and `ModuleResolutionKind`, and neither enum is reachable from any
  exported path (microsoft/TypeScript#64067).
- Diagnostics carry flattened text and offsets, so nothing needs flattening.
- Symbols hand back handles, and types answer through accessors.
- `package-exported-types` reads a package's declaration entry from its
  manifest and its `exports` map, since there is no resolver to ask
  (microsoft/TypeScript#64069).
- The visit methods for kinds the compiler no longer produces are removed, all
  of them JSDoc or Bundle, none reachable.

`(symbol as any).parent` returned a private numeric handle rather than a
symbol, which the compiler rejected as an empty symbol handle at runtime;
`getParent()` is the accessor.
Three defects kept the parser from round-tripping, taking it from 377 of 644
parser tests to 614.

Token start skipped whitespace only, so a comment ahead of a token left its
start equal to its full start and the prefix came back empty, dropping the
comment. Trivia is whitespace and comments both, which is what skipTrivia
walks.

Children were rebuilt on every ask, and callers locate a node among its
siblings by identity, so the lookups failed. They are built once per node
now.

A NodeArray reports `hasTrailingComma` unset whatever the source says, so a
trailing comma is read back off the text.

(cherry picked from commit 939b8f054e323e3dc386eaf6278100cbbd86af70)
A named member holds whichever of `?` and `!` follows its name in one
`postfixToken`, where they used to be separate fields, so optional members
and definite assignments were both being dropped.

A scanned token stands for punctuation the compiler leaves out of its tree
and has no identity in the compiler process, which rejects it with
`getNodeId requires a RemoteNode`; the type mapping asks about real nodes
only.

634 of 644 parser tests pass, from 614.

(cherry picked from commit b255265e1d742db452795b6391868491e8816871)
The compiler reparses JSDoc into the tree: a `@param` or `@returns` type
becomes a type annotation on the declaration it documents, positioned inside
the comment it came from and flagged Reparsed. Read as syntax it prints as an
annotation the source never had, so those nodes are left out of a node's
children and out of the type information the LST carries.

It also reports syntax errors from inside JSDoc, which the previous release
let a parser turn off and this one does not, so a diagnostic whose position
falls in a comment is not treated as a parse failure.

636 of 644 parser tests pass, from 634.

(cherry picked from commit a5d78e0a4f278b45a51315915fa7553e1e6dd17c)
An elision in a binding pattern is a binding element with nothing bound
rather than an omitted expression, so it has no name to map and the hole
stands on its own.

Sources held in memory were reachable only at the root the session opened
at: a directory holding them reported neither its entries nor its own
existence, so an import of a file below the root resolved to nothing and
everything it named came back untyped. Both answers now account for the
sources in hand.

All 644 parser tests pass.

(cherry picked from commit ea7f163a82fb731a46a0dab60008ceb1608f441e)
The suite is green on TypeScript 7: 2035 tests, the same as on 6.

Type attribution reaches the checker through the type node enclosing a name,
which is what an identifier in a type position resolves through; asking about
the identifier itself answers `any`. Module names are derived against the
root the session opens at, since the paths reaching the compiler are absolute.

Package resolution is done here, as the compiler exposes no resolver: an
`exports` map is read for the condition that names declarations, trying
`import` before `require`, and the declaration files are looked for in the
package built for the running platform, which is where they ship.

A class exported as the default carries `default` as its symbol name rather
than the name it was written with, which is only on the declaration, so
qualified names are built from there.

(cherry picked from commit 0b5797b7958fa567d1ec603ecc6dc54e7bb1c210)
The TypeScript 6 package the conformance test compares against declares the
same `tsc` bin as TypeScript 7, and npm linked that one, so the scripts were
compiling against 6 while the module runs on 7. They name the compiler by
path instead.

(cherry picked from commit 0d1ed738de1eb3c45b2b1096099d46e70c8b412e)
A parse was starting a compiler and loading its libraries each time, which
costs 59ms against the 0.09ms the parse itself takes. The process now lives
as long as the parser and a project is opened per batch, taking the suite
from 205s to 126s.

Reusing it means a path can be parsed twice with different text, and the
compiler answers with what it read the first time, so each batch announces
its sources as changed.

(cherry picked from commit 70c562d3503f7041deb77672c50b79d0cbd37eeb)
@knutwannheden knutwannheden changed the title Reach the TypeScript 7 AST and program through unstable/ast Run the JavaScript parser on TypeScript 7 Aug 28, 2026
Base automatically changed from typescript-7-exposure-in-rewrite-javascript to main August 28, 2026 11:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant