Skip to content

Expose the full Amaro loader API via 'node:module' #64518

Description

@mcollina

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

  1. 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.

  2. 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.

  3. 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.

  4. "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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    moduleIssues and PRs related to the module subsystem.strip-typesIssues or PRs related to strip-types support

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions