Contents

09/26/2026

Run TypeScript in Node.js without a build (type stripping)

Node runs .ts files directly now. node main.ts works on Node 24 with no flag, no ts-node, no tsx and no compile step, because Node erases the type annotations and runs what is left. The Node.js TypeScript documentation marks type stripping stable as of v24.12.0 and v25.2.0, and Node 24.15 ran the files below without printing any warning.

// types.ts
export interface User { id: number; name: string }
export type Role = 'admin' | 'viewer';
// greet.ts
import type { User, Role } from './types.ts';

export function greet(user: User, role: Role = 'viewer'): string {
  return `${user.name} (#${user.id}) as ${role}`;
}
// main.ts
import { greet } from './greet.ts';
console.log(greet({ id: 7, name: 'Ada' }, 'admin'));
> node main.ts
Ada (#7) as admin

The catch is in the word stripping. Node deletes types; it does not compile TypeScript. Anything that needs code generated for it fails, and three import habits from bundler-based projects fail too.

Node does not type-check

This is the first thing to internalize. A wrong type runs anyway:

const n: number = 'not a number' as unknown as number;
console.log(typeof n); // string

Node printed string and exited 0. Type checking is still tsc's job, run with --noEmit in your editor, a pre-commit hook or CI. What goes away is the build, not the checker.

Imports need the .ts extension

Node resolves files the way it resolves JavaScript: exactly as written. An extensionless import that a bundler would have resolved fails at runtime:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '...\greet' imported from ...\noext.ts

Write ./greet.ts. tsconfig.json paths aliases are not supported either; the Node docs point to subpath imports ("#lib/*" in package.json) instead.

Type-only imports must say so

When Node strips import { User } from './types.ts', it cannot know User was only a type, so it keeps the import, and the module has no runtime export by that name:

SyntaxError: The requested module './types.ts' does not provide an export named 'User'

Use import type { User }, or import { greet, type Options } when mixing values and types in one line.

Syntax that needs a compiler

Stripping works only for syntax that disappears cleanly. The Node documentation lists what does not: enum, namespace with runtime code, parameter properties, import aliases and import x = require(...), and decorators. All but decorators fail with the same error code; a decorator is rejected as a plain SyntaxError: Invalid or unexpected token:

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode

Type-only namespaces, which hold nothing but types, are fine.

Replacing an enum

A const object plus a derived type gives the same autocompletion and the same checking, and it is plain JavaScript once the types are gone:

export const Color = { Red: 'red', Green: 'green' } as const;
export type Color = (typeof Color)[keyof typeof Color];

const c: Color = Color.Green;
console.log(c); // green

Replacing a parameter property

Write the fields out. It is two more lines and it strips cleanly:

class Point {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

The transform flag, and why not to rely on it

Node 24 still has --experimental-transform-types, which does generate code for enums and friends: node --experimental-transform-types enum.ts printed 1 alongside an ExperimentalWarning. The Node documentation records that flag as removed in v26.0.0. Code that depends on it will not run on Node 26 without a compiler, so rewrite the syntax rather than reach for the flag.

Let tsc catch all of this first

Every runtime failure above can be a type error in your editor instead. This is the configuration the Node docs recommend, with strict added:

{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true
  }
}

With that file, TypeScript 7.0.2's tsc reported each problem file before Node ever saw it:

enum.ts(1,6): 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'. Did you mean './greet.js'?
novalue.ts(1,10): error TS1484: 'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
paramprop.ts(1,27): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

What each option buys:

  • erasableSyntaxOnly refuses enums, runtime namespaces and parameter properties.
  • verbatimModuleSyntax forces import type for type-only imports.
  • module: nodenext makes extensionless relative imports an error.
  • rewriteRelativeImportExtensions permits .ts in import paths and rewrites them to .js if you ever do emit.

One trap: tsc refuses import './greet.ts' unless one of two options is on. With both allowImportingTsExtensions and rewriteRelativeImportExtensions off, it reported TS5097: An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled. Either option clears it. Use allowImportingTsExtensions if the project never emits JavaScript, and rewriteRelativeImportExtensions if it might.

Limits worth knowing before you commit to it

  • Your dependencies are not stripped. Importing a .ts file from a package failed with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, so published packages still have to ship JavaScript.
  • .tsx is unsupported. JSX needs transforming, which is outside strip-only mode.
  • "type": "module" still decides the module system. A .ts file follows the same ESM or CommonJS rules as a .js file in the same place; .mts and .cts force one or the other.

For scripts, CLIs, servers and internal tools, that is usually enough to delete a build step and a dev dependency. Node's own streams and file APIs work the same from a .ts file, so a script like reading a huge file line by line needs only its types added. Keep tsc --noEmit in CI, and the checker you rely on is still there.

What to write instead

enum
A const object with as const, plus a derived type
Parameter properties
Declare the fields and assign them in the constructor
Extensionless import './greet'
Write './greet.ts'
Importing a type as a value
import type { User }
tsconfig paths aliases
Subpath imports (#lib/*) in package.json

erasableSyntaxOnly and verbatimModuleSyntax in tsconfig.json turn each of these into an editor error first.

Questions this raises

Can Node.js run TypeScript files directly?

Yes. Node 22.18 and 23.6 turned type stripping on by default, and the Node documentation marks it stable from v24.12.0 and v25.2.0. node main.ts runs with no flag, as long as the file uses only syntax that can be erased.

Does Node type-check TypeScript?

No. Node deletes the type annotations and runs what is left, so a type error runs anyway. Keep tsc --noEmit in your editor, a pre-commit hook or CI.

Why does Node say TypeScript enum is not supported in strip-only mode?

An enum generates JavaScript, and Node only removes types; it never generates code. Replace the enum with a const object and a derived union type, or compile the file with tsc.

Do I still need ts-node or tsx?

Not for code that sticks to erasable syntax. You still need a compiler or bundler for .tsx files, for TypeScript inside node_modules, and for enums, runtime namespaces, parameter properties or decorators.

Filed under