Node.js Dropped --experimental-transform-types: What Breaks and How to Fix It
Node.js 26 (and 24.12+/25.2+) removed --experimental-transform-types. Here's exactly why your TypeScript enums broke and how to fix them.
On this page
Somewhere in the Node.js 26 release notes, sandwiched between a Temporal API announcement and a GCC version bump, is a single bullet point that broke more projects than anything else in that changelog: --experimental-transform-types is gone. Not deprecated. Not warned about for a release or two first. Removed.
If you have been running TypeScript enums, namespaces, or parameter properties directly with node and that flag, your code stopped running the moment you upgraded. And here is the part that catches teams off guard: this is not a Node 26 story. The same removal landed in Node 25.2.0 and, far more importantly, in a patch release of the Node 24 LTS line, 24.12.0. If your CI pins 24.x instead of an exact version, you can wake up to a red pipeline without a single major version number changing anywhere in your repo.
This guide walks through exactly what changed, why enums and namespaces were never going to survive the transition, and how to migrate every affected pattern before the October 2026 LTS cutover forces the issue.

What's Actually Gone
Node's native TypeScript support runs your .ts file through amaro, a thin wrapper around the Rust-based SWC compiler. There were always two modes, and only one of them was ever meant to be permanent.
Strip mode, originally behind --experimental-strip-types and the default behaviour since Node 22.18.0 and 24.3.0, deletes type annotations and replaces them with whitespace. A : number becomes four spaces. Line numbers and column offsets stay identical, which is why stack traces from a stripped file still point at the right place with no source map. Nothing about the JavaScript underneath changes, because nothing needs to.
Transform mode did more. Enums, namespaces, and constructor parameter properties do not merely carry type information, they compile into different runtime code. An enum becomes an object with forward and, for numeric members, reverse key mappings. A namespace becomes an IIFE that assigns onto a namespace object. A parameter property becomes an explicit this.x = x assignment inside the constructor body. Transform mode ran that compilation step inside Node itself.
That is the part the Node TSC decided not to keep carrying. A transform is not a stable target: it changes as TypeScript changes, and it puts Node in the position of shipping and versioning a TypeScript compiler in core. Strip mode has no such problem, because it does not need to know what TypeScript means, only which characters are annotations. After roughly two years behind an experimental flag, transform mode was removed with no replacement.
Strip vs Transform, the Difference That Matters
| --experimental-strip-types | --experimental-transform-types | |
|---|---|---|
| Status today | Default behaviour, no flag needed | Removed in 24.12.0, 25.2.0, and 26.0.0 |
| What it does | Deletes type annotations, replaces them with whitespace | Compiled non-erasable syntax into real runtime JavaScript |
| Enums, namespaces, parameter properties | Rejected with a syntax error | Supported, while it existed |
| Source maps | Not needed, offsets are preserved exactly | Needed, because generated code shifted line numbers |
| Replacement | None needed, this is the supported path | None. Rewrite the code instead |
You no longer choose between these. Strip-only is all Node ships, so the decision moved out of a flag and into your source code.
The mental model worth internalising is simple, and it is the same rule the TypeScript team encoded into erasableSyntaxOnly: if deleting the syntax leaves valid JavaScript behind, it strips. If new JavaScript has to be generated in its place, it does not. Every breakage in this post is a direct consequence of that one sentence.
The Error You'll Actually See
Run a file containing a plain enum on Node 26 (or 24.12+, or 25.2+) and you get a syntax error before a single line of your program executes:
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]:
x TypeScript enum is not supported in strip-only mode
,-[1:1]
1 | export enum Direction {
: ^^^^^^^^^^^^^^^^^^^^^^^
2 | Up, Down, Left, Right
3 | }
`----Namespaces with runtime content and constructor parameter properties produce the same error code with a different message. Two details about this failure are worth calling out, because both surprise people.
First, it is a parse error, not a runtime one. The file does not partially execute. Nothing above the offending line runs, no side effect fires, and no try/catch anywhere in your application will catch it. From your process manager's point of view the service simply refused to boot.
Second, your editor will not warn you about it. TypeScript considers all of this syntax completely valid, because it is: tsc compiles enums and namespaces happily and always has. The compiler has no opinion about what Node's stripper can handle unless you explicitly ask it to have one, which is the next thing to fix.

Which Node Versions Removed It
| Release line | Flag removed in | Released | Why it catches people |
|---|---|---|---|
| 24.x LTS | 24.12.0 | Patch release | An LTS line dropped a feature in a patch. Pinning 24.x or ^24.0.0 pulls it in with no major bump. |
| 25.x Current | 25.2.0 | Minor release | Same removal one line up. Expected on a Current line, but still a minor. |
| 26.x | 26.0.0 | May 2026 | Shipped without the flag from day one. Enters LTS in October 2026, when most fleets upgrade in bulk. |
Turn On erasableSyntaxOnly First
Before rewriting anything, make the compiler tell you where the problems are. TypeScript 5.8 added a compiler option built specifically for this transition. With erasableSyntaxOnly enabled, tsc reports every non-erasable construct as a compile error, in your editor, as you type, instead of at runtime in a CI job:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"noEmit": true
}
}verbatimModuleSyntax belongs in the same change, and it is the flag people skip. Without it, an import of something that turns out to be a type gets left in the output as a real import. Stripping preserves it, Node tries to resolve a value that does not exist at runtime, and you get a module resolution error that looks nothing like a type problem. With verbatimModuleSyntax on, TypeScript forces the type keyword on type-only imports and the ambiguity disappears.
If you want the whole config generated rather than assembled by hand, the tsconfig Builder outputs a Node-ready file with both flags already set.
Fixing the Four Things That Break
Four patterns account for essentially every failure. Each has a mechanical rewrite, and three of the four are pure syntax swaps with no behaviour change at all.
- 1
Enums become const objects
Replace the enum with a
constobject markedas const, then derive a type of the same name from it. TypeScript keeps types and values in separate declaration spaces, so one name can be both. That is what makes this rewrite so cheap: every call site and every annotation keeps compiling untouched.ts — direction.ts// Before: needs a runtime transform, so Node refuses the file export enum Direction { Up = 'UP', Down = 'DOWN', } // After: fully erasable, identical call sites export const Direction = { Up: 'UP', Down: 'DOWN', } as const; export type Direction = (typeof Direction)[keyof typeof Direction]; // Both of these still compile exactly as before const d: Direction = Direction.Up; function move(dir: Direction) {}const enumis not a safer alternative here, it is a worse one. It fails under strip-only mode for the same reason, and it was already discouraged underisolatedModulesbecause inlining its values requires whole-program knowledge that per-file transpilers do not have. Use the sameas constrewrite for both. - 2
Namespaces become plain modules
First, check whether you actually have a problem. A namespace containing only interfaces and type aliases compiles to nothing at all, strips cleanly, and needs no change. Only a namespace holding runtime values or functions generates the IIFE that strip-only mode cannot produce.
When it does hold runtime content, the fix is to delete the wrapper. An ES module already provides the scoping the namespace was there to give you, which is why namespaces have been discouraged in module code for years.
ts — config.ts// Before: compiles into an IIFE that builds a Config object namespace Config { export const timeout = 5000; const secret = 'internal'; export function load() { return timeout; } } // After: the file itself is the namespace export const timeout = 5000; const secret = 'internal'; // still module-private, same as before export function load() { return timeout; }Call sites change from
Config.timeoutto a plain import oftimeout. Members that were not exported inside the namespace become module-private, which is exactly the visibility they had before, so nothing leaks. If you want to preserve the grouped call style during a gradual migration,import * as Config from './config.js'gives you the sameConfig.timeoutshape with no namespace involved. - 3
Parameter properties become explicit fields
A modifier on a constructor parameter is a shorthand that silently generates an assignment. Deleting the modifier would drop that assignment and quietly change what the constructor does, so the stripper refuses rather than guessing. Expand the shorthand into the code it was standing in for.
ts — user-service.ts// Before: private and readonly each generate a this.x = x assignment class UserService { constructor(private db: Database, readonly logger: Logger) {} } // After: the same class, written out class UserService { private db: Database; readonly logger: Logger; constructor(db: Database, logger: Logger) { this.db = db; this.logger = logger; } } - 4
Import-equals and export assignment become ES syntax
import X = require(...)andexport = Xare TypeScript's own CommonJS-flavoured module forms. There is no ES syntax hiding underneath them to uncover by deleting characters, so both are rejected. In almost every case this is a find-and-replace.ts// Before import fs = require('node:fs'); export = UserService; // After import fs from 'node:fs'; export default UserService; // If the module has no default export and esModuleInterop is off: import * as fs from 'node:fs';

If You're Not Ready to Migrate
Not every codebase can absorb this refactor before its next Node upgrade, and a large NestJS monolith full of parameter properties is a genuinely multi-sprint job. Three options buy you time.
Pin an exact Node version below the removal. Note that pinning the LTS line is not enough: 24.x and ^24.0.0 both resolve to 24.12.0 or later. You need a concrete version such as 24.11.1, set in .nvmrc, engines, your Volta config, and your Docker base image tag, because a single unpinned one of those undoes the other three.
Put a real compiler back in the loop. tsx and ts-node still transform enums, namespaces, and parameter properties, so they remain a working local-development and CI path. Compiling with tsc or esbuild before deploying sidesteps the question entirely, and if you already build before shipping then none of this affects your production artefact at all. It only ever affected running node file.ts with no build step.
For new services, Deno is worth a look: it runs enums and namespaces natively with no flag and never had this limitation, since it ships a full TypeScript compiler rather than a stripper. The Deno tutorial covers the runtime differences if you have not used it recently.
The Migration Checklist
- Check every Node version pin for a range across the 24 LTS line: .nvmrc, package.json engines, Dockerfile base image, and CI matrix
- Scan your entrypoints and largest service files for non-erasable syntax before estimating the work
- Add erasableSyntaxOnly and verbatimModuleSyntax to tsconfig.json so the compiler enforces this permanently
- Rewrite enums as const objects with a derived type of the same name
- Grep for numeric index lookups on any enum you migrate, since reverse mappings do not survive
- Unwrap namespaces that hold runtime values, and leave type-only namespaces alone
- Expand constructor parameter properties into explicit fields and assignments
- Replace import-equals and export-assignment syntax with ES imports and exports
- Confirm your DI container still resolves constructors once parameter modifiers are gone
- Run the full suite on the target Node version, not just a typecheck, since this is a parse-time failure
One closing note on sequencing. Because this is a parse error rather than a type error, a green tsc --noEmit proves nothing on its own unless erasableSyntaxOnly is on. Add the flag first, then migrate, then run the actual test suite on the actual target Node version. Teams that skip the last step tend to find the remaining files in production. If you are also mid-way through a compiler upgrade, the TypeScript 7 migration guide covers how to sequence both changes without stalling feature work.
Frequently Asked Questions
What's the difference between --experimental-strip-types and --experimental-transform-types?
| Feature | strip-types | transform-types |
|---|---|---|
| Status | Default behaviour, no flag needed | Removed in 24.12.0, 25.2.0, 26.0.0 |
| What it does | Deletes annotations, replaces with whitespace | Compiled non-erasable syntax into JS |
| Enums and namespaces | Rejected | Supported, while it existed |
| Source maps needed | No, offsets are preserved | Yes, generated code shifted lines |
| Replacement today | None needed | None. Rewrite the code |
You no longer choose. Strip-only is all Node ships, so the fix lives in your source code rather than in a flag.
Why do TypeScript enums break under Node's type stripping?
Node's stripper only deletes syntax that leaves valid JavaScript behind. An enum is not annotated with a type, it compiles into an object that has to be generated. Removing the enum keyword and leaving the body would produce invalid JavaScript, so Node refuses the file instead.
- Erasable syntax — type annotations, interfaces, type aliases, generics,
implementsclauses, andsatisfieshave no runtime representation, so deleting them is always safe - Non-erasable syntax — enums, namespaces with values, and parameter properties need new JavaScript generated in their place, which strip-only mode cannot do
- Ambient declarations —
declare enumanddeclare namespaceemit nothing at all, so they strip cleanly and are not affected
Which Node versions actually removed the flag?
- Node 24.12.0 — removed from the 24.x LTS line in a patch release
- Node 25.2.0 — removed from the 25.x Current line
- Node 26.0.0 — shipped without the flag from day one, LTS from October 2026
How do I fix "TypeScript enum is not supported in strip-only mode"?
Replace the enum with a const object and as const, then derive a type of the same name so existing annotations keep working:
const Direction = { Up: 'UP', Down: 'DOWN' } as const;
type Direction = (typeof Direction)[keyof typeof Direction];Does this affect me if I already compile with tsc or a bundler?
No. If you compile with tsc, esbuild, swc, or a bundler before deploying, none of this applies. Those compilers still fully support enums, namespaces, and parameter properties and always will.
This change only affects code run directly with node file.ts and no build step. Your production artefact is unaffected, though your local development scripts might not be if anyone runs TypeScript directly.
What about decorators?
It depends entirely on which kind of decorator you are using, and the two are indistinguishable from the syntax alone.
- Standard Stage 3 decorators — behaviour does not depend on a TypeScript-specific transform, so they are not a stripping problem
- Legacy `experimentalDecorators: true` — the form most dependency-injection frameworks still use, requires a TypeScript transform and is rejected under strip-only mode
- How to tell — open your
tsconfig.jsonand check theexperimentalDecoratorsflag, because the decorator syntax itself is identical either way
If your codebase depends on legacy decorators, it needs a real compile step regardless of what you do about enums. There is no erasable equivalent to migrate to.
Is a namespace that only contains types safe?
Yes. A namespace holding only interfaces and type aliases compiles to nothing, so it erases cleanly and Node runs it without complaint. You do not need to refactor it.
// Safe: erases to nothing
namespace Api {
export interface User { id: string }
export type Id = User['id'];
}
// Breaks: one runtime value is enough to require the IIFE
namespace Api {
export const VERSION = 2;
}How do I find every affected file without upgrading Node first?
- Fastest, per file — paste a file into TypeStripCheck for a full list of findings with line numbers and rewrites, no install and no config change
- Authoritative, whole project — add
erasableSyntaxOnlytotsconfig.jsonand runtsc --noEmit, which reports every occurrence as a compile error - Crude but zero-setup — grep for
enum,namespace, and= require(to get a rough count, accepting false positives from comments and strings
The removal of --experimental-transform-types is a small changelog line with an outsized blast radius, mostly because it reached an LTS line through a patch release. The migration itself is mechanical: four patterns, three of them pure syntax swaps. The part worth doing carefully is the audit, and the part worth doing permanently is erasableSyntaxOnly, so this never has to be a migration again. For the other TypeScript tooling break to plan around this year, see why the TypeScript 7 upgrade broke ESLint and ts-jest.
Related Articles
Why Your TypeScript 7 Upgrade Broke ESLint, ts-jest, and ts-morph
TypeScript 7's native compiler breaks typescript-eslint, ts-jest, and ts-morph. Here's exactly why, and the side-by-side setup that fixes it.
TypeScript 7 Migration Guide: tsgo, Breaking Changes, Build Times
Migrate to TypeScript 7 (tsgo): install the beta, fix the 4 breaking changes, update tsconfig, and decide if upgrading now is worth it.