Back to Tsx

Native TypeScript type stripping

notes/node/type-stripping.md

4.23.115.5 KB
Original Source

Native TypeScript type stripping

Node's built-in TypeScript execution pipeline, how it evolved, and which remaining TypeScript runtime gaps tools such as tsx still fill.

Timeline

ChangePR / issueVerified releasesWhy it matters to tsx
--experimental-transform-typesnodejs/node#54283v22.7.0, v23.0.0Node once had an opt-in transform mode for non-erasable syntax (enum, runtime namespaces). It is not the floor tsx benchmarks against.
module.stripTypeScriptTypes() API#55282, nodejs/node#54300v22.13.0, v23.2.0Exposes type stripping as a public node:module API; useful for embedders, not a full loader.
TypeScript in --eval / STDIN#56359v22.14.0, v23.6.0, v24.0.0Native TS isn't only file loading; the CLI eval/stdin path also strips types.
Unflag --experimental-strip-types#56350, nodejs/typescript#17v22.18.0, v23.6.0, v24.0.0This is tsx's nativeTypeScript gate: Node runs strip-only TS by default.
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX#56610v22.14.0, v23.7.0, v24.0.0Distinguishes unsupported non-erasable TS from invalid syntax.
Compile-cache integration#56629, nodejs/node#54741v23.7.0, v24.0.0Native TS can cache stripped/transformed output; cold/warm comparisons need to account for Node's compile cache.
Recommend erasableSyntaxOnly#57271v22.15.0, v23.10.0, v24.0.0Aligns TypeScript's checker with Node's strip-only runtime.
Remove experimental warning#58643, nodejs/typescript#24v22.18.0, v24.3.0Strip-only became quiet by default, but still release-candidate until stable.
Stable#60600, nodejs/typescript#24v24.12.0, v25.2.0Stable docs status; tsx's gate predates this because it tracks availability, not stability label.
Remove --experimental-transform-types#61803, nodejs/typescript#51v26.0.0Node 26+ is strip-only. Non-erasable transforms remain a gap tools such as tsx fill.
Type-stripped CJS sourceURL fix#63705v26.4.0CJS type-stripped scripts now use file: URLs for sourceURL, aligning inspector behavior with ESM.

Runtime pipeline

  • Node delegates stripping to Amaro: amaro.transformSync is loaded in typescript.js#L47-L48. The public-ish internal API stripTypeScriptTypes starts at typescript.js#L101, and everything routes through processTypeScriptCode at typescript.js#L144.
  • ESM format detection maps .ts to module-typescript / commonjs-typescript based on package type and syntax detection (v24.0.0 get_format.js#L133-L149). In v26 the extension map is explicit for .ts / .mts / .cts (get_format.js#L31-L33) before the same package/syntax logic later in the file (get_format.js#L189-L202).
  • The CommonJS loader strips TS formats before compile. v24.0.0 handles module-typescript / commonjs-typescript / typescript at loader.js#L1125-L1127, and v26 has the same strip-before-compile path via stripTypeScriptModuleTypes at loader.js#L182 and the TS-format cases at loader.js#L1125-L1127.
  • --eval and STDIN use the same internal TypeScript evaluator: v22.14.0 wires evalTypeScript into eval_string (eval_string.js#L39-L68) and eval_stdin (eval_stdin.js#L37-L47).
  • Stripping runs after the module-hook chain, not before it. The ESM loader loads source through the hook chain first, then translates by the final format (v24.15.0 loader.js#L408-L414); the commonjs-typescript and module-typescript translators call stripTypeScriptModuleTypes on whatever source the hooks returned (v24.15.0 translators.js#L628-L643). A load hook that returns TypeScript source with a -typescript format gets native stripping; a hook that rewrites the format opts out of it.
  • For .ts files in a package without a type field, the format follows the same contract as .js, including syntax detection. Detection happens at load time, not resolve time: defaultResolve reports a null format for these files, and the module system is determined when the source is read.
  • tsx maps explicit module-typescript / commonjs-typescript resolve results directly to module / commonjs because Node already determined the module type (v24.15.0 get_format.js#L186-L207). When Node returns no format, tsx retains its package lookup and legacy CommonJS default, including for typeless .ts files.
  • The transpiled output participates in the compile cache and V8 code cache keyed by source text and URL at the C++ layer (v24.15.0 module_wrap.cc#L518-L527), so any module compilation — including source produced by customization hooks — is cacheable when the compile cache is enabled.

Public stripTypeScriptTypes() vs the internal loader path

node:module exposes stripping as a public API, but it is a different code path from what the loaders use, with different behavior:

AspectPublic stripTypeScriptTypes()Internal stripTypeScriptModuleTypes()
node_modulesNo restrictionRefuses with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING (v24.15.0 typescript.js#L180-L183)
Compile cacheNot usedKeyed by filename via getCompileCacheEntry (v24.15.0 typescript.js#L198)
Mode'strip' or 'transform' accepted without any CLI flag through v25.x (v24.15.0 typescript.js#L112); v26 accepts only 'strip' (v26.4.0 typescript.js#L101)Follows --experimental-transform-types until its removal in v26
WarningEmits an ExperimentalWarning on first call, still present in v26 (v26.4.0 typescript.js#L92)None

In strip mode, types are replaced with whitespace so line and column positions match the source and no source map is produced; sourceMap: true is only valid in transform mode. The docs warn that output should not be considered stable across Node versions (v24.15.0 module.md#L281), so consumers should not key caches or assertions on exact output bytes.

Amaro reports two error classes, surfaced as distinct codes: ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX for valid TypeScript that requires transformation (enum, runtime namespace, parameter properties, import =/export = aliases), and ERR_INVALID_TYPESCRIPT_SYNTAX for source that does not parse. Decorators are neither: they are treated as JavaScript syntax and pass through stripping untouched, so they reach V8 as-is. TypeScript's erasableSyntaxOnly checker option flags exactly the unsupported-syntax set — not decorators or accessor, which are checker-legal JavaScript — so a project that type-checks under erasableSyntaxOnly never triggers the unsupported-syntax error at runtime.

What Node intentionally does not do

The Node docs frame native TypeScript as a lightweight strip-only runtime, not a TypeScript compiler:

Node recommends TypeScript 5.8+ and a checker configuration that matches strip-only runtime behavior (v24.12.0 typescript.md#L80-L99, v26.0.0 typescript.md#L93-L108):

json
{
    "compilerOptions": {
        "noEmit": true,
        "target": "esnext",
        "module": "nodenext",
        "rewriteRelativeImportExtensions": true,
        "erasableSyntaxOnly": true,
        "verbatimModuleSyntax": true
    }
}

How those options relate to Node:

  • erasableSyntaxOnly makes TypeScript reject non-erasable syntax before runtime. Node docs started recommending it after nodejs/node#57271; the TypeScript option exists for this exact runtime contract.
  • verbatimModuleSyntax forces type-only imports to be written as import type, matching Node's runtime rule that it does not erase imports based on type information.
  • rewriteRelativeImportExtensions is for projects that emit JavaScript; for type-check-only scripts that Node runs directly, allowImportingTsExtensions is the checker-side option that permits explicit .ts specifiers. Node's docs mention it because the runtime requires explicit .ts extensions (v26.0.0 typescript.md#L136-L139).
  • module: nodenext is the closest checker model for Node's .ts/.mts/.cts module classification. Node does not transform module syntax across systems: ESM source needs ESM syntax, and CJS source needs require/module.exports (v26.0.0 typescript.md#L110-L126).

Relationship to tsx

Native stripping covers the common, erasable-syntax case. That is the ideal path for projects that fit it: less tooling, less startup overhead, and fewer compatibility layers. tsx remains useful for the gaps Node intentionally leaves to tools:

  • tsx transforms syntax Node rejects or no longer supports transforming (enum, parameter properties, JSX/TSX, decorators, CJS TypeScript syntax, import aliases);
  • tsx implements extension and path behavior Node explicitly leaves to tools (paths, extensionless imports, .js specifiers that map to .ts, TypeScript under dependency graphs when needed);
  • tsx handles CJS/ESM interop and named exports around transformed source (see cjs-esm-interop.md);
  • tsx's benchmark native-ts scenario measures the runtime floor Node provides, while esm-ts measures the extra transform and resolution surface tsx adds for those gaps.
  • Module-system classification differs for one population: for .ts files in a package without a type field, Node applies the same syntax detection it applies to .js, while tsx's format detection predates detect-module and classifies those files as CommonJS. Code relying on ESM-only semantics (such as top-level await) in a typeless package runs under plain node but is converted to CommonJS by tsx.

Remaining gaps Node is closing

  • node_modules restriction: #58429, #58626, #63853. If Node allows TS in workspace dependencies, tsx can re-audit how much dependency transform/resolution support users still need.
  • JSX / .tsx: #56322, #56822. If Node accepts .tsx, tsx's native-floor benchmark changes substantially and another major gap closes.
  • Ecosystem adaptation after default-on stripping: #59364. Important when deciding whether to widen tsx's native fast paths.
  • Source identity: #63705 uses file: URLs for type-stripped CJS sourceURL; tsx should keep its own data-URL/sourceURL behavior aligned with inspector expectations.