Run TypeScript in Node Without ts-node or tsx

Node can run TypeScript without ts-node. Since v22.18.0 and v23.6.0 it strips type annotations out of .ts files before executing them, with no flag and no build step, and the feature reached Stability 2 in v24.12.0. node src/server.ts works, so ts-node and tsx can come out of package.json for most services and CLIs.

node src/server.ts

Stripping is not compiling, though, and that difference is the whole story. Four pieces of TypeScript syntax carry runtime behaviour of their own, Node refuses all four, and Node 26 removed the flag that used to transform them. Get those out of your codebase and the runtime stops caring how your source is written.

Which Node versions run TypeScript without ts-node?

Type stripping landed behind --experimental-strip-types in v22.6.0 and was turned on by default in v23.6.0, backported to v22.18.0. The experimental warning stopped printing in v24.3.0 and v22.18.0. The Node.js TypeScript docs mark the feature Stability 2 as of v24.12.0 and v25.2.0, and v24 (Krypton) is the Active LTS line, so this is available on the version most teams are already running in production.

Under the covers Node uses Amaro, a thin wrapper around @swc/wasm-typescript. It does not rewrite your code. It overwrites every type annotation with spaces, which keeps line and column numbers identical to the source, so stack traces point at the right place and there is nothing to configure for source maps. As of August 2026 Amaro tracks TypeScript 5.8.

Three rules follow from the whitespace approach, and each one bites at least once:

File extensions are mandatory. import './db.ts' is correct, import './db' throws. The same applies to require('./db.ts').

.tsx is not supported at all, in any Node version. React and JSX projects still need a bundler.

TypeScript inside node_modules is ignored on purpose. Published packages ship JavaScript plus .d.ts files, as they always have.

Module resolution for .ts follows the same rule as .js: "type": "module" in package.json makes it ESM, otherwise CommonJS. .mts is always ESM and .cts is always CommonJS. If you have not moved a Node service across yet, our write-up on adopting ES modules in JavaScript projects covers that half of the job.

Turn the whole thing off with --no-strip-types if something in your pipeline expects .ts to be unloadable.

What syntax does type stripping refuse?

Anything TypeScript-specific that emits code. You get ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX at load time, before a single line runs:

// All four throw ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
enum Status { Active, Suspended }

namespace metrics {
  export const counter = 0;
}

class Client {
  constructor(private readonly id: string) {}
}

import legacy = require('./legacy.cjs');

Decorators fail too, with a parser error rather than that code, because they are still a TC39 Stage 3 proposal and Node implements no version of them. If your codebase runs on TypeORM, older NestJS, or MobX, native stripping is not an option today.

Each of the four has a plain JavaScript equivalent that costs nothing:

// The same four, written so they erase cleanly
const Status = {
  Active: 'active',
  Suspended: 'suspended',
} as const;
type Status = (typeof Status)[keyof typeof Status];

// metrics.ts, used as: import * as metrics from './metrics.ts'
export const counter = 0;

class Client {
  readonly #id: string;
  constructor(id: string) {
    this.#id = id;
  }
}

import legacy from './legacy.cjs';

The const-object pattern is a better enum anyway: the values are the strings you see in logs, not opaque integers, and there is no object hanging around at runtime that TypeScript pretends is a type.

Type-only namespace blocks are still fine. It is only the ones containing runtime values that fail.

How do I make tsconfig.json agree with the runtime?

The runtime does not read tsconfig.json, which means nothing stops you writing syntax that type-checks cleanly and then explodes on node src/server.ts. TypeScript 5.8 added erasableSyntaxOnly to close exactly that gap. It reports enums, runtime namespace blocks, parameter properties and import = / export = aliases as errors, with the message “This syntax is not allowed when ‘erasableSyntaxOnly’ is enabled.” See the TypeScript 5.8 release notes for the full list.

The Node docs recommend this set:

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

verbatimModuleSyntax is the one people skip and regret. Without it, TypeScript quietly elides imports it decides are type-only, and stripping cannot make that decision because it never resolves a module. Write import type { Config } from './config.ts' or import { load, type Config } from './config.ts' and it works; write import { Config } for a type and you get a runtime error about a missing export.

rewriteRelativeImportExtensions, added in TypeScript 5.7, is what lets you keep ./db.ts in your imports while still producing a working build with tsc. It rewrites relative .ts specifiers to .js on emit. It does not touch bare specifiers, paths aliases or package.json exports targets.

noEmit says the quiet part out loud: tsc is now your type checker, not your compiler. Run it in CI, because nothing at runtime will ever tell you a type is wrong.

What replaced —experimental-transform-types in Node 26?

Nothing in core. Node 26.0.0, released 5 May 2026, lists “module: remove —experimental-transform-types” as a semver-major change. The flag that compiled enums and parameter properties into JavaScript is gone with no replacement, so a codebase that only ran because of it now fails to load on Node 26 while continuing to work on Node 24.

Three ways out, in the order I would try them. Rewrite the syntax, as above, which is usually an afternoon and leaves you with fewer moving parts. Or keep the transform through Amaro directly, which is the same code Node used to ship:

node --enable-source-maps --import="amaro/transform" src/server.ts

Source maps matter there because transforming, unlike stripping, moves your line numbers. Or go back to tsx or a real tsc build, which is the honest answer for anything decorator-heavy.

When is a build step still the right call?

Bundling, for one. Cold starts on Lambda and Cloudflare Workers care about file count and total bytes, and stripping does not tree-shake, minify or concatenate anything. Our serverless architecture work usually still ends at an esbuild or Rollup step for that reason.

paths aliases are the other common blocker. The runtime ignores tsconfig.json entirely, so @/lib/db resolves to nothing. Node’s own subpath imports do the same job and both tools understand them: add "imports": { "#lib/*": "./src/lib/*.ts" } to package.json and import #lib/db.ts.

For a monorepo, --conditions is worth knowing. Point a custom export condition at your TypeScript sources and packages resolve to .ts in development without a watch build in between:

node --watch --conditions=typescript --import="amaro/strip" ./src/index.ts

For a new Node service on v24 or later, start with native stripping, erasableSyntaxOnly on from the first commit, and tsc --noEmit in CI. You get node --watch src/server.ts with no loader, no dev dependency and no transpile cache to go stale. Keep tsx for the projects that genuinely need decorators or JSX, and keep a bundler wherever deployment size is what you are being judged on. The rest of it, most of it, no longer needs the tooling we all installed by reflex. If you want a second pair of eyes on that decision, our Node.js consultancy does this sort of migration regularly.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch