Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Dev.to
Discord
WhatsApp Channel
daily.dev
Hashnode
X

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /Node.js Dropped --experimental-transform-types: What Breaks and How to Fix It
typescript13 min read

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.

Zeeshan Tofiq
Zeeshan Tofiq
August 22, 2026
On this page

On this page

  • What's Actually Gone
  • Strip vs Transform, the Difference That Matters
  • The Error You'll Actually See
  • Which Node Versions Removed It
  • Turn On erasableSyntaxOnly First
  • Fixing the Four Things That Break
  • Enums
  • Namespaces
  • Parameter Properties
  • Import Equals and Export Assignment
  • If You're Not Ready to Migrate
  • The Migration Checklist
  • Frequently Asked Questions

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.

Diagram comparing two pipelines: strip-only mode showing a TypeScript file with type annotations replaced by whitespace and the JavaScript underneath unchanged, next to transform mode showing an enum being compiled into a full runtime object with forward and reverse key mappings
Strip mode deletes characters. Transform mode generated new JavaScript. Only one of those two survived.

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 todayDefault behaviour, no flag neededRemoved in 24.12.0, 25.2.0, and 26.0.0
What it doesDeletes type annotations, replaces them with whitespaceCompiled non-erasable syntax into real runtime JavaScript
Enums, namespaces, parameter propertiesRejected with a syntax errorSupported, while it existed
Source mapsNot needed, offsets are preserved exactlyNeeded, because generated code shifted line numbers
ReplacementNone needed, this is the supported pathNone. 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:

bash — node app.ts
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.

Terminal window showing the command node app.ts followed by a SyntaxError reading ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, TypeScript enum is not supported in strip-only mode, with a caret underlining the export enum Direction line
The failure is a parse error, so nothing in the file runs. A service hitting this in CI never reaches its own startup logging.

Which Node Versions Removed It

Most coverage frames this as a Node 26 concern. The 24 LTS patch release is the one that actually breaks pipelines nobody planned to change.
Release lineFlag removed inReleasedWhy it catches people
24.x LTS24.12.0Patch releaseAn LTS line dropped a feature in a patch. Pinning 24.x or ^24.0.0 pulls it in with no major bump.
25.x Current25.2.0Minor releaseSame removal one line up. Expected on a Current line, but still a minor.
26.x26.0.0May 2026Shipped without the flag from day one. Enters LTS in October 2026, when most fleets upgrade in bulk.

🚫 Check your pin before you check your code

If your Dockerfile, .nvmrc, or CI matrix says node:24, 24.x, or ^24.0.0, you are already on the far side of this change or one rebuild away from it. Range pins across an LTS line are exactly what turned a flag removal into a surprise outage for teams who never opened the Node 26 changelog.

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:

json — tsconfig.json
{
  "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.

💡 Scan a file before you change your tsconfig

Turning on erasableSyntaxOnly changes type-checking for the whole project, which is a lot of ceremony just to answer "how bad is this?". TypeStripCheck takes a pasted file and lists every enum, namespace, parameter property, and import-equals declaration that will break, with the line number and the rewrite for each. Nothing installed, nothing uploaded, no config touched.

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

    Enums become const objects

    Replace the enum with a const object marked as 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 enum is 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 under isolatedModules because inlining its values requires whole-program knowledge that per-file transpilers do not have. Use the same as const rewrite for both.

    ⚠ Numeric enums are the one real behaviour change

    A numeric enum also generates reverse mappings, so Status[0] returns 'Active'. An as const object has no reverse mapping and that lookup returns undefined. Before migrating a numeric enum, grep for index-style lookups on it, especially in logging, serialisation, and database adapter code where the numeric form often leaks.

  2. 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.timeout to a plain import of timeout. 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 same Config.timeout shape with no namespace involved.

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

    ℹ This one hurts dependency-injection codebases most

    Parameter properties are the default constructor style in NestJS and Angular-shaped projects, so a single service file can carry four or five of them and a large codebase can carry thousands. The rewrite is mechanical, but if your container reads constructor metadata from decorators, verify it still resolves the arguments once the modifiers are gone before you migrate the whole tree.

  4. 4

    Import-equals and export assignment become ES syntax

    import X = require(...) and export = X are 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';

    ⚠ export = changes your consumers too

    Switching a module from export = X to export default X is a breaking change for anything importing it with import X = require('...'). If the module is published or shared across teams, coordinate both sides of the change in the same release.

Horizontal timeline showing type stripping becoming default in Node 22.18 and 24.3, the transform-types flag removal landing in Node 24.12.0 patch, 25.2.0, and 26.0.0 in May 2026, and Node 26 entering LTS in October 2026
The removal reached the 24 LTS line months before Node 26 becomes the default LTS, which is why teams hit it without planning an upgrade.

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?
Featurestrip-typestransform-types
StatusDefault behaviour, no flag neededRemoved in 24.12.0, 25.2.0, 26.0.0
What it doesDeletes annotations, replaces with whitespaceCompiled non-erasable syntax into JS
Enums and namespacesRejectedSupported, while it existed
Source maps neededNo, offsets are preservedYes, generated code shifted lines
Replacement todayNone neededNone. 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, implements clauses, and satisfies have 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 enum and declare namespace emit 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

⚠ Warning

Because the removal landed in a patch release of Node 24, pinning ^24.0.0 or 24.x in CI pulls in the breaking change without any major version bump appearing in your repo.

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:

ts
const Direction = { Up: 'UP', Down: 'DOWN' } as const;
type Direction = (typeof Direction)[keyof typeof Direction];

⚠ Warning

Numeric enums support reverse mappings, so Direction[0] returns the member name. An as const object does not. Check for numeric lookups before migrating a numeric enum.

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.json and check the experimentalDecorators flag, 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.

ts
// 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?
  1. 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
  2. Authoritative, whole project — add erasableSyntaxOnly to tsconfig.json and run tsc --noEmit, which reports every occurrence as a compile error
  3. Crude but zero-setup — grep for enum , namespace , and = require( to get a rough count, accepting false positives from comments and strings

ℹ Info

Whichever you use to survey the damage, leave erasableSyntaxOnly switched on permanently afterwards. It is the only one of the three that prevents the syntax coming back in a future pull request.

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.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

typescript

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.

Jul 14, 2026·11 min read
typescript

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.

Jun 15, 2026·25 min read

On this page

  • What's Actually Gone
  • Strip vs Transform, the Difference That Matters
  • The Error You'll Actually See
  • Which Node Versions Removed It
  • Turn On erasableSyntaxOnly First
  • Fixing the Four Things That Break
  • Enums
  • Namespaces
  • Parameter Properties
  • Import Equals and Export Assignment
  • If You're Not Ready to Migrate
  • The Migration Checklist
  • Frequently Asked Questions