Developer Tools

Run TypeScript in Node.js Without a Build Step: Type Stripping Tested on Node 22, 24 and 26

node app.ts works on Node 22.18+, 24 and 26 with no flags. I tested what runs, what fails with which error, the tsconfig that catches it, and startup time against tsx.

Node.js type stripping cover: interfaces and import type run with node app.ts, while enums, namespaces, decorators and extensionless imports fail
On this page
  1. How Node.js type stripping works
  2. What works out of the box
  3. What fails (tested on Node 22, 24 and 26)
  4. --experimental-transform-types is gone in Node 26
  5. The tsconfig.json that catches every failure
  6. Replacing enums
  7. Startup time: node vs tsx vs precompiled JS
  8. When to use built-in type stripping

You can now run a .ts file with plain node app.ts, no tsx, no ts-node, no build step. Type stripping has been on by default since Node.js 22.18.0 and 23.6.0, and it became stable in Node.js 24.12.0 and 25.2.0. On Node.js 26 LTS it is simply how Node works.

But "Node runs TypeScript" has sharp edges. Enums fail. Parameter properties fail. Decorators fail. Imports without .ts fail. And Node never checks your types. I tested every one of these on Node.js 22.20.0, 24.22.2 and 26.13.0, and this post shows the exact errors, the tsconfig.json that catches all of them before runtime, and how startup time compares to tsx and precompiled JavaScript.

How Node.js type stripping works#

Node replaces TypeScript type annotations with whitespace and runs what is left as JavaScript. It does not type-check, it ignores tsconfig.json, and it only accepts erasable syntax: TypeScript that disappears completely when you delete the types.

From the Node.js TypeScript docs history table:

Node.js versionChange
v22.6.0Type stripping added behind --experimental-strip-types
v23.6.0, v22.18.0Enabled by default
v24.3.0, v22.18.0No more experimental warning
v24.12.0, v25.2.0Marked Stable
v26.0.0--experimental-transform-types flag removed

You can check it at runtime: node -p "process.features.typescript" printed strip on all three versions I tested.

What works out of the box#

This file ran unchanged with node ok.ts on Node 22.20, 24.22 and 26.13:

ts
// ok.ts
interface User { id: number; name: string }
type Role = 'admin' | 'user';
import type { Stats } from 'node:fs';
import { greet } from './util.ts';

const u: User = { id: 1, name: 'Waseem' };
const role: Role = 'admin';
function size<T>(xs: T[]): number { return xs.length; }
const s = undefined as Stats | undefined;

console.log(greet(u.name), role, size([1, 2, 3]), s);
// hi Waseem admin 3 undefined

Interfaces, type aliases, generics, as casts, import type and annotated functions are all erasable, so they just work. Add "type": "module" to package.json; without it, Node prints a MODULE_TYPELESS_PACKAGE_JSON warning and reparses the file as ESM.

What fails (tested on Node 22, 24 and 26)#

All three versions gave the same result for every case below.

TypeScript featureWhat Node prints
enum Color { Red, Green }SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
namespace A { export const x = 1 }SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript namespace declaration is not supported in strip-only mode
constructor(public name: string)SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode
import { greet } from './util'Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../util'
import { User } from './types.ts' (User is an interface)SyntaxError: The requested module './types.ts' does not provide an export named 'User'
@log class K {}SyntaxError: Invalid or unexpected token
A .tsx fileTypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".tsx"
A .ts file inside node_modulesError [ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING]

Three of these surprise people the most:

  1. Type-only imports must say import type. Node removes the type but keeps the import statement, so it looks for a runtime export called User and crashes.
  2. Decorators don't run. That means NestJS, TypeORM entities and anything with @Injectable() still needs a compile step or a loader like tsx or SWC.
  3. Packages can't ship raw .ts. Node refuses to strip types under node_modules, so libraries still need to publish JavaScript.

--experimental-transform-types is gone in Node 26#

On Node 24, node --experimental-transform-types enum.ts ran the enum (with an ExperimentalWarning). On Node 26.13.0 the same command printed:

text
node: bad option: --experimental-transform-types

If you relied on that flag for enums, Node 26 is the point where you have to remove them or go back to a loader.

The tsconfig.json that catches every failure#

Node ignores tsconfig.json, but your editor and tsc don't. With this config, tsc flagged every runtime failure above (except decorators, see below) before I ran anything:

json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "skipLibCheck": true
  }
}

Output from tsc -p . with TypeScript 5.9.3:

text
enum.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
ns.ts(1,11): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
paramprop.ts(1,23): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
noext.ts(1,23): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
typeimport.ts(1,10): error TS1484: 'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

What each option does:

  • erasableSyntaxOnly (TypeScript 5.8+) errors on enums, namespaces and parameter properties.
  • verbatimModuleSyntax forces import type for type-only imports.
  • allowImportingTsExtensions lets you write ./util.ts, which is what Node needs. It requires noEmit (or rewriteRelativeImportExtensions if you also emit).
  • module: nodenext makes tsc require file extensions like Node does.

The gap: decorators. TypeScript 5.9 accepts standard decorators, so tsc passed dec.ts, but Node 26 still threw a SyntaxError. If your code uses decorators, keep a build step for that code.

Run tsc --noEmit in CI and in a pre-commit hook. Node won't check types for you.

Replacing enums#

The usual erasable replacement is a const object plus a type:

ts
export const Color = { Red: 0, Green: 1 } as const;
export type Color = (typeof Color)[keyof typeof Color];

Startup time: node vs tsx vs precompiled JS#

Same ok.ts (two small files), Node 26.13.0, 30 interleaved runs each, wall time for the whole process:

How it ranMedianp90
node js/ok.js (precompiled with tsc)27.7 ms33.0 ms
node ok.ts (type stripping)62.6 ms67.2 ms
tsx ok.ts (tsx 4.23.15)116.9 ms128.5 ms

Built-in stripping was about half the startup time of tsx, but about 35 ms slower than running precompiled JavaScript on this machine. For scripts, CLIs, dev servers and tests that difference doesn't matter. For a serverless function where cold start counts, shipping compiled JavaScript is still faster.

When to use built-in type stripping#

Use node file.ts for:

  • Scripts, seeds, migrations and one-off tools
  • CLIs and internal services you control end to end
  • Running tests with node --test on .ts files
  • Dev mode with node --watch src/server.ts

Keep a build step (tsc, SWC, esbuild or tsx) for:

  • NestJS and anything else that uses decorators
  • React and .tsx files
  • Libraries you publish to npm
  • Code that relies on paths aliases in tsconfig.json (Node ignores them)
  • Serverless functions where cold start matters

If you're upgrading Node at the same time, see Node 24 vs Node 26 for the other breaking changes, and TypeScript 7 for where the TypeScript compiler itself is going.

Questions