feat: lex type-only TypeScript imports and exports#220
Conversation
fdfdeca to
c18c022
Compare
guybedford
left a comment
There was a problem hiding this comment.
Thanks Ruben — this is a strong first increment: the write-head sentinel guards are correct, the minimal build reader/typings are properly insulated, and the inline-modifier semantics get the subtle case right (import { type A } keeps a runtime edge in Node's stripping output, so statement-level tp: false there is exactly right). Requesting changes on the items below.
Bug: import type from 'x' must be tp: false
Verified against both Node stripTypeScriptTypes (v26) and tsc 5.9 verbatimModuleSyntax: this form is a value import of the default export bound to the local name type — both keep it verbatim. Only import type from from 'x' (default import named from) is type-only:
import type from 'm'; => kept verbatim (value import)
import type from from 'm'; => stripped (type-only)
The PR marks the first tp: true, and the test comment ("Node strips the whole import") is incorrect. Since import type from 'x' is also valid plain JavaScript, a consumer eliding tp: true imports would drop a real runtime import from a pure-JS file — this breaks the JS-superset guarantee the PR is built on. The type follower exclusions need a third case: from followed (after whitespace/comments) by a quote. Please add import type from from 'x' as the disambiguation test.
Gating must be complete — zero cost to the minimal build
src/lexer.c is fully behind #ifdef LEX_TS, but src/lexer.h is not:
bool type_only;onstruct Import/struct Export- the
type_only = falsestores inaddImport/addExport - the
itp()/etp()accessors
The minimal build currently pays two live stores per record and +4 bytes heap per export (the bool lands between pointer fields in the min Export layout), and only DCE keeps itp/etp out of the min wasm. All of these need #ifdef LEX_TS. This composes with the asm requirement below: the asm build defines LEX_TS and gets the field for real; the minimal build carries zero trace.
asm.js build must be included
The hand-maintained-dictionary blocker no longer exists: the build now auto-extracts the keyword dictionary from the fastcomp memory image (the lib/lexer.asm.in.js task scans lexer.layout.js.mem and substitutes {{WORDS}}/{{OFFSET}}), so adding YPE propagates automatically. Concretely:
-D LEX_TSand_itp/_etpinEXPORTED_FUNCTIONSon both fastcomp tasks (lib/lexer.emcc.asm.jsand the layout sidecar must keep identical layout-affecting flags)src/lexer.asm.jsnon-minimal branch:tp: !!asm.itp()instead of hardcodedfalse- run the
test/typescript/suites underASM=1—_harness.cjshard-importsdist/lexer.js; give it the sameWASM/ASMenv switch the legacy suites use - fix the now-stale "MANUAL ASM DICTIONARY CONSTRUCTION" note at the top of
lexer.c, and the README/lexer.tsJSDoc claims that the asm build is JS-only
Type declarations must be tracked
export type Foo = ... and export interface Foo {} need to record tp: true exports — top-level type / interface dispatch from the statement-start check exactly like import/export. Implementation notes:
- For
type Foo, read the name, then stop at the=and skip the RHS. Note default type parameters put a=before the real one (type Foo<T = string> = ...), so after the name skip a balanced<...>region first (>>closes two,=>in function types doesn't close,{}/[]/()nest inside). The same angle skipper servesinterface Foo<T> extends Bar<T> {. - Interface bodies should be skipped opaquely (brace-matching, not the main token loop): a member like
import(): voidotherwise matches the dynamic-import dispatch and records a bogus import edge — a hazard that exists latently today for bareinterfaceblocks. - Guards for the JS superset: require an identifier-start char after the keyword (
type = 5,type(x)are plain JS) and no line break betweentypeand the name (type\nX = 5is two JS statements via ASI). - Dotted names can't be declared (
export type Foo.Baris invalid TS); qualified names only appear as RHS references (export type Bar = Foo.Bar;), which the RHS skip covers for free.import Bar = Foo.Bar/export importare non-erasable and stay out of scope. - Scope boundary: bare
interface Foo {}+ laterexport { Foo }should not be resolved — Node stripping does no cross-binding analysis (erasable TS requiresexport { type Foo }), so only directly-exported declaration forms are marked.
Smaller items
isTsTypeKeywordfollower set missesimport type/*c*/{ A } from 'm'(verified: Node strips it; the lexer reportstp: false) andimport type* as ns from 'm'. Accepting/and*as followers is safe — the callers' savePos/restore logic already disambiguates.- The single-token
aslookahead in the export brace loop mishandlesexport { type as/*c*/T }andexport { type as as X }(type modifier on a specifier namedas). Pathological, but worth a deliberate call — at minimum a comment. - README opening ("lexes the erasable TypeScript syntax that Node.js type stripping accepts") overstates this increment; scope it to the type-only subset until the annotation work lands.
- Untested:
export type * from 'x'(traced as correct — import edgetp: true— but pin it), and thetp: falseadditions in the JS builds once the harness is env-parameterized. - Nit:
js-build-unchanged.cjsruns against the wasm build;js-superset.cjswould name what it actually asserts.
c2fe15e to
e32a1c8
Compare
|
This is still a WIP PR with some issues and not yet ready. It should move in the right direction though. |
guybedford
left a comment
There was a problem hiding this comment.
This is really nicely done — pinning the accept/reject boundary to Node's stripTypeScriptTypes and backing it with a differential fuzzer is exactly the right approach for an opaque skipper, and it clearly earned its keep (several tests are annotated "fuzzer-found"). The minimal build staying zero-cost behind #ifdef LEX_TS and the JS-superset guarding are both thorough.
Two things I'd like addressed before merge:
1. import type from'x' (no space before the quote) is mis-marked type-only. In the import type detection:
if (!typeIsBinding && nextCh == 'f' && memcmp(pos + 1, &ROM[0], 3 * 2) == 0 && isBrOrWs(*(pos + 4))) {The from-followed-by-quote check requires whitespace after from. import type from'x' is valid JS (type default binding, from keyword, 'x' with no separating space), but it fails isBrOrWs(*(pos + 4)), so typeIsBinding stays false and the import is reported tp: true — dropping a real runtime edge from a file that is also valid JavaScript, which breaks the "no JS consumer observes a change" invariant. The fuzzer misses it because the import-type-from form always emits a space before the specifier. Fix is to also accept a quote here, e.g. isBrOrWs(c) || isQuote(c), and add the no-space case to the fuzzer's form.
2. Type/interface declarations nested in a block still leak their import(...) types. Both bare triggers gate on openTokenDepth == 0:
if (*(pos + 1) == 'y' && openTokenDepth == 0 && keywordStart(pos))
tryTsTypeDeclaration(true);So function f() { type X = import('m').T; } — which Node strips cleanly — reports import('m') as a runtime edge. This is a remaining gap rather than a regression (everything leaked before), and the openTokenDepth == 0 guard is a reasonable defense against expression-context misfires, so I'm fine leaving the behaviour for a follow-up. But the README currently says type and interface declarations are skipped "whether exported or not" with no nesting caveat — please add a one-line scope note there so the limitation is documented, and ideally extend the fuzzer to emit nested declarations (it only generates top-level statements today, so it can't surface this class).
3. Prefer forcing the keyword dictionary contiguous over reconstructing it from a gappy span. The gen-asm-in.mjs change reconstructs the dictionary across the whole first-to-last span, filling gaps with NUL, because adding the new tables made the compiler place nterface at a non-adjacent offset. That works, but scanning first-to-last across the entire memory image is sensitive to any unrelated printable-char16 constant landing between the tables (it would widen the span and bloat words with a long NUL run).
The split happens only because each keyword is a separate static const char16_t[] object, which the compiler/linker may reorder or interleave. Merging them into one array and offsetting into it keeps the object contiguous on both toolchains (no linker script or section attributes needed — those don't actually guarantee intra-section ordering):
static const char16_t KEYWORDS[] = {
'x','p','o','r','t', // xport
'm','p','o','r','t', // mport
/* ... */
'y','p','e', // ype
'n','t','e','r','f','a','c','e', // nterface
};
#define XPORT (KEYWORDS + 0)
#define MPORT (KEYWORDS + 5)
/* ... */The call sites already pass explicit lengths (memcmp(pos + 1, &MPORT[0], 5 * 2)) and never use sizeof, so only &NAME[0] becomes NAME. That lets the asm extraction go back to "grab the single contiguous run", drops the NUL-gap span logic and its end - start fragility, and the contiguous blob (no gap NULs) is likely smaller than today's span.
229afd6 to
a03578e
Compare
Review — type-only TypeScript import/export lexingThe architecture is sound and test coverage is reasonable, but the new speculative TS-skipping paths in Correctness (all confirmed)1. 2. 3. 4. 5. These five share a root cause worth fixing at altitude: after an opaque declaration erasure, the skipper should restore tokenizer state to what an erased statement looks like — leave Efficiency (confirmed, lower severity)6. 7. Minor
|
The lexer only understood JavaScript, so a TypeScript module's `import type` / `export type` either parsed as a value import/export or failed outright (issue guybedford#72: `export type { A } from 'x'`). Running a separate TypeScript transform first is no faster than what Node.js type stripping already does at runtime, and pulling a full TypeScript parser (swc) into the package would dwarf its footprint. Instead the C lexer learns the syntax directly and the full Wasm build (`lib/lexer.wasm`, compiled with `-D LEX_TS`) enables it by default. Plain JavaScript pays nothing: the added work is one keyword compare per import/export statement, and every type-only form is a syntax error in JavaScript except `import type from 'x'`, which resolves to the same module specifier — so no JavaScript consumer observes a change. The asm.js / CSP build stays JavaScript-only until its hand-maintained fastcomp keyword dictionary is extended; it reports `tp: false`. Type-only imports and exports are reported rather than elided, marked with a new `tp` boolean on every import and export specifier. Inline modifiers are tracked per specifier, so `export { type A, b }` marks only A. The accept/reject boundary matches Node.js type stripping: this first increment covers type-only imports and exports; annotations, generics, `as`/`satisfies` and the rest of the erasable surface follow next. The minimal build (es-module-shims) lexes JavaScript only and never sets `tp`, so its reader and generated `.d.ts` drop the field; only the full build carries it. Fixes: guybedford#72
…uild
Extends the type-only lexing so the reported import/export graph matches
Node.js type stripping across the whole supported surface, not just clause
imports/exports.
1. `type` / `interface` declarations are skipped opaquely whether exported or
not, so an `import(...)` type in an alias right-hand side, a generic default,
an interface heritage clause, or an interface body no longer leaks as a
runtime import. Bare declarations record no export; the export forms record a
type-only export. The skipper walks characters directly (comments, strings,
templates, and balanced `<> () [] {}` runs) and never re-enters the
tokenizer, which is both cheaper than the previous `skipExpression` round-trip
and the reason a member like `load(): import('m')` cannot record a bogus edge.
2. `import type from 'x'` is a value import of a default binding named `type`
that Node keeps verbatim, so it now reports `tp: false`; only
`import type from from 'x'` is type-only. Marking the first form `tp: true`
would drop a real runtime import from a file that is also valid JavaScript.
3. The asm.js / CSP build lexes TypeScript too: the keyword dictionary is
auto-extracted from the fastcomp memory image, so both fastcomp tasks build
with `-D LEX_TS` and export `itp` / `etp`, and the reader returns real `tp`.
The dictionary blob is injected as a JSON string literal so NUL gap bytes
stay valid JS.
4. The type-only field, its stores, and the `itp` / `etp` accessors are fully
behind `#ifdef LEX_TS`, so the minimal build carries no `type_only` field and
pays nothing.
Alias right-hand sides end at a `;`, EOF, or an ASI line break only once the
type is complete: a trailing prefix keyword (`keyof`, `typeof`, `readonly`,
`unique`, `infer`, `new`, `abstract`, `import`) or a `|` / `&` beginning the
next line keeps the type open, matching TS.
An `import(...)` type in a value-position annotation (`const x: import('m').T`)
or a value-position generic argument (`f<import('m').T>()`) is still reported as
a runtime import: separating a type argument from a `<` comparison needs full
type context, so it stays out of scope alongside `as` / `satisfies`.
Fixes: guybedford#72
…backtick
A `type` / `interface` declaration whose erased body held a template-literal
type ending in a `${ ... }` substitution (`type S = \`${import('m').T}\``) lost
the statement that followed it: skipTsTrivia advanced past the `}` via
skipTsBalanced and then the loop's `++pos` skipped the closing backtick too, so
the scan ran on into the next statement. A trailing runtime import/export edge
was dropped, and a following balanced type could desync the scanner into a
spurious parse error on input Node strips cleanly. Step back one char after the
substitution so the backtick is re-read.
Fixes: guybedford#72
Generates erasable TypeScript, strips it with Node's stripTypeScriptTypes as the oracle, and asserts the lexer reports the same runtime import and export graph as the stripped output. Runtime edges the lexer keeps must survive stripping and edges that survive stripping must be reported, in both directions, so a declaration skipper that overruns its terminator and swallows the next statement is caught, not just a leaked `import(...)` type reference. Findings shrink to a one-line repro. Runs against the Wasm or asm.js build. Fixes: guybedford#72
Static `import type from'x'` was marked type-only, and nested `type` / `interface` declarations leaked erased `import(...)` references into the runtime graph. Keeping every keyword tail in one static object also prevents the asm dictionary extractor from spanning linker-created gaps and copying unrelated data.
Opaque declaration erasure left tokenizer state pointing at skipped braces or operands. A following slash could read an uninitialized opener, leak regex contents as imports, or let an alias consume its enclosing block. Unbalanced speculative type parameters now stop after one EOF scan instead of rescanning quadratically. Reusing the existing reader results for the type-only bit removes one boundary call per specifier; Node 24.18.0 / V8 13.6 measured 1.2% faster on rollup.min.js and 2.6% on an eight-import/eight-export fixture across seven interleaved trials (trimmed mean).
d0e2fa7 to
593ca50
Compare
Summary
Teaches the full Wasm and asm.js builds to lex the type-only TypeScript subset that Node's
stripTypeScriptTypeserases: type-only import/export clauses andtype/interfacedeclarations. Type-only module edges are reported withtp: true, and declaration bodies are skipped opaquely so nestedimport()types do not leak into the runtime graph.Why
A single lexer avoids a separate TypeScript transform and gives module-graph consumers both runtime and type-only edges. Plain JavaScript remains metadata-compatible. TypeScript declaration lookahead only runs after shallow inline keyword gates, and
tpreuses existing import/export reader values instead of adding a Wasm boundary call per specifier. Minimal builds remain JavaScript-only and compile out the type-only fields and paths.The supported boundary follows Node's type stripping for these forms. Value-position annotations and generics,
as/satisfies, non-null!, and non-erasable syntax such asenumand runtimenamespaceremain out of scope.Fixes: #72