astradevlabsastradevlabs
← All posts
Dev Tips4 min

Node 24 Runs Your TypeScript With No Build Step. Here Is How to Not Get Burned.

Dev Tips

For a decade, running TypeScript meant running a compiler first. You wrote .ts, tsc (or esbuild, or swc, or ts-node) turned it into .js, and Node ran the output. Node 24, the current LTS, quietly ends that for a large class of projects: point node at a .ts file and it just runs. No flag, no build step, no dist/ directory. That is genuinely useful, but it works by removing types rather than understanding them, and the difference is where people get burned. Here is what actually changed and how to set up a project that uses it without surprises.

What "native TypeScript" really means

Node does not bundle the TypeScript compiler. When it loads a .ts, .mts, or .cts file, an internal module called amaro (a thin wrapper around swc) strips the type annotations out and hands plain JavaScript to the engine. Interfaces, type aliases, : string annotations, generics, import type — all erased. Crucially, the stripped tokens are replaced with whitespace, not deleted, so line and column numbers stay identical and your stack traces still point at the right place.

The flag history is worth knowing because old blog posts will mislead you. Node 22 introduced --experimental-strip-types. Node 23 turned it on by default. Node 24 made it stable (as of v24.3.0) with no flag at all. So this runs today:

bash
node server.ts

The catch: stripping is not compiling

Because amaro only erases, anything that needs generated runtime code is off the table. The big three:

ts
// enum -> emits a runtime object, NOT erasable
enum Role { Admin, User }

// namespace -> emits runtime code
namespace Utils { export const x = 1; }

// constructor parameter properties -> need a transform
class Service {
  constructor(private db: Database) {}
}

Node will throw on these rather than guess. The --experimental-transform-types flag that used to handle them was removed in v25.2.0, so do not plan around it. Path aliases from tsconfig.json (@/utils) and decorator emission are also unsupported, because both need real AST transformation. If your codebase leans on those, you still want tsc or a bundler.

The fix for the erasable-syntax problem is to let the compiler warn you before runtime does. TypeScript 5.8 added erasableSyntaxOnly:

json
{
  "compilerOptions": {
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true,
    "module": "nodenext",
    "noEmit": true
  }
}

With erasableSyntaxOnly, the compiler flags an enum or parameter property at type-check time, so you find it in the editor instead of in a 2 a.m. pager. verbatimModuleSyntax keeps your imports honest about what is a type and what is a value, which matters once nothing is rewriting them.

The other catch: nothing checks your types

This is the part teams miss. Stripping removes types; it never validates them. node app.ts will happily run code with a dozen type errors in it, because by the time the engine sees the file, the types are gone. The runtime is not your type checker.

So you still need an explicit check, and CI is where it belongs:

json
{
  "scripts": {
    "start": "node src/server.ts",
    "typecheck": "tsc --noEmit",
    "test": "node --test"
  }
}

Run npm run typecheck in your pipeline and treat a failure as a build failure. The mental model that keeps you safe: Node is the runtime, tsc is the linter for types, and the two no longer happen in the same step. Forget the second one and TypeScript silently degrades into JavaScript with extra syntax.

Import extensions actually matter now

Without a bundler rewriting paths, your imports have to be real. That means writing the extension, and writing the one that exists on disk:

ts
import { createServer } from "./server.ts";

Set allowImportingTsExtensions so the compiler permits .ts in import specifiers, and rewriteRelativeImportExtensions if you also emit with tsc for distribution and need .ts rewritten to .js in the output. Inside a pure run-natively workflow, pointing imports at the .ts file is correct.

When to actually use this

Reach for native stripping on scripts, internal tools, test files, small services, and anything where a build step was pure overhead. The payoff is real: no watch process, no source-map juggling, faster cold starts in dev, one less moving part in your container.

Keep a real build for libraries you publish (consumers expect .js and .d.ts), apps that depend on decorators (many ORMs and DI frameworks still do), and anything using enums or namespaces you are not ready to refactor. Bun and Deno strip types too, so the pattern is now table stakes across runtimes rather than a Node quirk.

The short version: delete the build step where you can, add a typecheck step in CI so you do not lose type safety doing it, and turn on erasableSyntaxOnly so your editor catches the one class of mistake the runtime will not.

References