Node.js bundles Amaro (currently v1.1.10) and uses it for the default
type-stripping loader, but exposes almost none of it. After
nodejs/node#61803 removed --experimental-transform-types, there is no
way to run TypeScript syntax that requires transformation
(enums, namespaces with runtime code, parameter properties, import aliases),
and TypeScript files under node_modules are always rejected with
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.
The escape hatch we document today is npm install amaro plus
--import=amaro/transform — installing from npm the exact same code that
already ships inside the Node.js binary.
This proposal exposes the bundled Amaro loader as a small, runtime-configurable
API on node:module, so that full TypeScript support, including files inside
node_modules, is 1–3 lines of code away, with no flags and no dependencies:
// enable-ts.mjs
import { configureTypeScript } from 'node:module';
configureTypeScript({ mode: 'transform', nodeModules: true });
$ node --import ./enable-ts.mjs app.ts
Motivation
-
The capability already ships in the binary. deps/amaro/dist/index.js
exports transformSync(source, { mode: 'transform', sourceMap, filename }),
and deps/amaro even contains the ready-made transform-loader. Core only
wires up mode: 'strip-only'. Telling users to install amaro from npm to
unlock functionality that is compiled into their node executable is hard
to justify, adds a supply-chain surface, and risks version skew between the
bundled SWC and the npm one.
-
The removal of --experimental-transform-types left a real gap.
The flag was removed (nodejs/node#61803) rather than stabilized, which
was the right call for a default: erasable-only syntax is the direction
the TypeScript ecosystem is heading (erasableSyntaxOnly). But existing
codebases full of enums and runtime namespaces can no longer run on Node.js
directly at all. An explicit, in-code opt-in serves those users without
weakening the default.
-
The node_modules ban is policy, not capability. We refuse to strip
types under node_modules to discourage publishing raw TypeScript to npm.
That is sound as a default, but it is the application author — not
Node.js — who pays when a dependency (a monorepo workspace package, a git
dependency, an internal registry package) ships .ts files. Today their
only options are a build step or a userland loader. An explicit opt-in
keeps the discouragement (packages still cannot assume it works) while
unblocking the people who consciously accept the trade-off.
-
"On the fly" configuration matches the existing hooks model.
module.registerHooks() already lets code synchronously customize
resolution and loading at runtime. TypeScript configuration should be
equally programmatic instead of frozen at process start.
Current state
| Capability |
Status today |
| Type stripping (erasable syntax) |
On by default (--strip-types) |
| Enums, runtime namespaces, etc. |
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, no opt-in |
TypeScript under node_modules |
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING |
module.stripTypeScriptTypes() |
mode: 'strip' only (transform removed in #61803) |
Amaro transformSync full options |
Bundled, internal-only |
| Amaro transform loader |
Bundled, internal-only; users re-install it from npm |
Proposed API
1. module.configureTypeScript(options) — the 1-liner
A process-wide (per-thread, see Worker threads)
configuration call that reconfigures the built-in TypeScript pipeline used
by both the CJS and ESM loaders, --eval, and STDIN input.
const { configureTypeScript } = require('node:module');
configureTypeScript({
// 'strip' (default) erases types only, no source maps needed.
// 'transform' enables full TypeScript syntax: enums, namespaces,
// parameter properties, import aliases. Emits inline source maps.
mode: 'transform',
// Allow transpiling TypeScript files located under node_modules.
// Default: false.
nodeModules: true,
// Emit inline source maps in transform mode. Default: true when
// mode is 'transform' (locations change), ignored in strip mode
// (whitespace replacement preserves locations).
sourceMaps: true,
});
Returns the previous configuration, so wrappers can save/restore. Calling it
with no arguments returns the current configuration without changing it.
Semantics:
- Takes effect for every module compiled after the call. Already-loaded
modules are not retranspiled; entries already in the on-disk compile cache
made with a different configuration are not reused (the cache key includes
the mode, see Compile cache).
- It configures the default steps, so it composes correctly with
module.registerHooks() and async module.register() hooks: user hooks
still run first and can short-circuit; whatever falls through to the default
load uses the configured mode.
- Works with all existing entry points that support TypeScript today:
.ts/.mts/.cts files via import/require, --eval, STDIN.
- Errors keep their current codes: syntax that even
transform cannot handle
(e.g. decorators pre-TC39-native) still throws
ERR_INVALID_TYPESCRIPT_SYNTAX / ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX with
the Amaro snippet decoration.
Because the entry point itself must be loadable before the call runs, the
recommended pattern is --import (which also covers worker threads via
inherited execArgv):
$ node --import ./enable-ts.mjs app.ts
For projects whose entry point is plain JS (or strip-compatible TS), calling
it at the top of the entry point works too — that's the true 1-line case:
require('node:module').configureTypeScript({ mode: 'transform', nodeModules: true });
2. module.stripTypeScriptTypes() — restore mode: 'transform'
Re-extend the existing public transpiler API (reverting the API-surface part
of #61803) so the full Amaro transformSync capability is reachable for
tooling that wants direct source-to-source transforms:
const { stripTypeScriptTypes } = require('node:module');
const js = stripTypeScriptTypes(code, {
mode: 'transform', // 'strip' | 'transform'
sourceMap: true, // transform mode only
sourceUrl: 'file:///app/enums.ts',
});
This is the low-level building block: configureTypeScript() is sugar over
running this inside the default load step. Exposing both mirrors the
registerHooks() philosophy — a convenient default plus composable
primitives. It also means userland loaders (tsx, ts-node, test runners,
coverage tools) can drop their own SWC/esbuild binaries and rely on the copy
Node.js already ships.
3. Non-goal: a resolvable node:amaro builtin
We deliberately do not propose import amaro from 'node:amaro'. Amaro is
an implementation detail (a wrapper around a pinned SWC build); branding the
module namespace with it would lock us in. Everything is exposed through
node:module under TypeScript-named APIs, keeping the freedom to swap the
underlying transpiler.
Usage examples
Run a legacy codebase full of enums, unchanged:
$ node --import ./ts.mjs ./src/main.ts # ts.mjs: 2 lines
Monorepo where workspace packages ship raw .ts (symlinked under
node_modules):
// instrument once at the app entry
require('node:module').configureTypeScript({ nodeModules: true });
A test runner enabling full support only for the duration of a run:
const prev = configureTypeScript({ mode: 'transform', nodeModules: true });
await runTests();
configureTypeScript(prev);
Trade-offs and risks
- Ecosystem signaling. The strongest argument for the status quo is that
strip-only + no-node_modules pressures the ecosystem toward erasable,
pre-built packages. This proposal keeps both defaults intact. The opt-in is
code the application author writes, exactly like installing amaro from
npm today — we are removing an npm round-trip, not changing the default
posture. Package authors still cannot rely on consumers having enabled it.
- Bundle surface becomes API surface. Exposing
transform mode means the
bundled SWC's transform behavior becomes observable and semver-relevant.
It already is, indirectly, through the npm amaro package that pins the
same SWC; documenting mode transform as release-notes-worthy when Amaro
is bumped is enough.
- Mid-flight reconfiguration can produce a process where some modules
were stripped and others transformed. This is the same class of
already-accepted behavior as registerHooks() being called at any time;
the docs should recommend configuring once, before loading application
code.
- Source-map cost. Transform mode emits inline source maps and benefits
from --enable-source-maps for accurate traces; the docs should say so
(the npm loader emits a warning — we can do the same once, lazily).
Node.js bundles Amaro (currently v1.1.10) and uses it for the default
type-stripping loader, but exposes almost none of it. After
nodejs/node#61803 removed
--experimental-transform-types, there is noway to run TypeScript syntax that requires transformation
(enums, namespaces with runtime code, parameter properties, import aliases),
and TypeScript files under
node_modulesare always rejected withERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.The escape hatch we document today is
npm install amaroplus--import=amaro/transform— installing from npm the exact same code thatalready ships inside the Node.js binary.
This proposal exposes the bundled Amaro loader as a small, runtime-configurable
API on
node:module, so that full TypeScript support, including files insidenode_modules, is 1–3 lines of code away, with no flags and no dependencies:$ node --import ./enable-ts.mjs app.tsMotivation
The capability already ships in the binary.
deps/amaro/dist/index.jsexports
transformSync(source, { mode: 'transform', sourceMap, filename }),and
deps/amaroeven contains the ready-madetransform-loader. Core onlywires up
mode: 'strip-only'. Telling users to installamarofrom npm tounlock functionality that is compiled into their
nodeexecutable is hardto justify, adds a supply-chain surface, and risks version skew between the
bundled SWC and the npm one.
The removal of
--experimental-transform-typesleft a real gap.The flag was removed (nodejs/node#61803) rather than stabilized, which
was the right call for a default: erasable-only syntax is the direction
the TypeScript ecosystem is heading (
erasableSyntaxOnly). But existingcodebases full of enums and runtime namespaces can no longer run on Node.js
directly at all. An explicit, in-code opt-in serves those users without
weakening the default.
The
node_modulesban is policy, not capability. We refuse to striptypes under
node_modulesto discourage publishing raw TypeScript to npm.That is sound as a default, but it is the application author — not
Node.js — who pays when a dependency (a monorepo workspace package, a git
dependency, an internal registry package) ships
.tsfiles. Today theironly options are a build step or a userland loader. An explicit opt-in
keeps the discouragement (packages still cannot assume it works) while
unblocking the people who consciously accept the trade-off.
"On the fly" configuration matches the existing hooks model.
module.registerHooks()already lets code synchronously customizeresolution and loading at runtime. TypeScript configuration should be
equally programmatic instead of frozen at process start.
Current state
--strip-types)ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, no opt-innode_modulesERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPINGmodule.stripTypeScriptTypes()mode: 'strip'only (transform removed in #61803)transformSyncfull optionsProposed API
1.
module.configureTypeScript(options)— the 1-linerA process-wide (per-thread, see Worker threads)
configuration call that reconfigures the built-in TypeScript pipeline used
by both the CJS and ESM loaders,
--eval, and STDIN input.Returns the previous configuration, so wrappers can save/restore. Calling it
with no arguments returns the current configuration without changing it.
Semantics:
modules are not retranspiled; entries already in the on-disk compile cache
made with a different configuration are not reused (the cache key includes
the mode, see Compile cache).
module.registerHooks()and asyncmodule.register()hooks: user hooksstill run first and can short-circuit; whatever falls through to the default
load uses the configured mode.
.ts/.mts/.ctsfiles viaimport/require,--eval, STDIN.transformcannot handle(e.g. decorators pre-TC39-native) still throws
ERR_INVALID_TYPESCRIPT_SYNTAX/ERR_UNSUPPORTED_TYPESCRIPT_SYNTAXwiththe Amaro snippet decoration.
Because the entry point itself must be loadable before the call runs, the
recommended pattern is
--import(which also covers worker threads viainherited
execArgv):$ node --import ./enable-ts.mjs app.tsFor projects whose entry point is plain JS (or strip-compatible TS), calling
it at the top of the entry point works too — that's the true 1-line case:
2.
module.stripTypeScriptTypes()— restoremode: 'transform'Re-extend the existing public transpiler API (reverting the API-surface part
of #61803) so the full Amaro
transformSynccapability is reachable fortooling that wants direct source-to-source transforms:
This is the low-level building block:
configureTypeScript()is sugar overrunning this inside the default load step. Exposing both mirrors the
registerHooks()philosophy — a convenient default plus composableprimitives. It also means userland loaders (tsx, ts-node, test runners,
coverage tools) can drop their own SWC/esbuild binaries and rely on the copy
Node.js already ships.
3. Non-goal: a resolvable
node:amarobuiltinWe deliberately do not propose
import amaro from 'node:amaro'. Amaro isan implementation detail (a wrapper around a pinned SWC build); branding the
module namespace with it would lock us in. Everything is exposed through
node:moduleunder TypeScript-named APIs, keeping the freedom to swap theunderlying transpiler.
Usage examples
Run a legacy codebase full of enums, unchanged:
$ node --import ./ts.mjs ./src/main.ts # ts.mjs: 2 linesMonorepo where workspace packages ship raw
.ts(symlinked undernode_modules):A test runner enabling full support only for the duration of a run:
Trade-offs and risks
strip-only + no-
node_modulespressures the ecosystem toward erasable,pre-built packages. This proposal keeps both defaults intact. The opt-in is
code the application author writes, exactly like installing
amarofromnpm today — we are removing an npm round-trip, not changing the default
posture. Package authors still cannot rely on consumers having enabled it.
transformmode means thebundled SWC's transform behavior becomes observable and semver-relevant.
It already is, indirectly, through the npm
amaropackage that pins thesame SWC; documenting mode
transformas release-notes-worthy when Amarois bumped is enough.
were stripped and others transformed. This is the same class of
already-accepted behavior as
registerHooks()being called at any time;the docs should recommend configuring once, before loading application
code.
from
--enable-source-mapsfor accurate traces; the docs should say so(the npm loader emits a warning — we can do the same once, lazily).