Node.js Runs TypeScript Now. Should You Delete Your Build Step?
Node 24 strips types at runtime, so `node server.ts` just works. That is genuinely useful — and it is not the same thing as Node supporting TypeScript. Here is where the line actually falls.

For about a decade, running TypeScript on the server meant accepting a build step. You wrote .ts, something turned it into .js, and you ran the output. Everyone had opinions about which something — tsc, ts-node, esbuild, tsx, swc — but nobody argued about whether.
Node 24 changed the default. You can run node server.ts and it works, no flag, no loader, no dist/ directory. This is a real improvement and I use it daily. It is also routinely oversold, in a way that will bite people who read the headline and skip the mechanism.
So: what is actually happening, and what does it mean for a production service.
Type stripping, not compilation
Node does not compile TypeScript. It erases it.
Internally, Node uses a module called amaro, which wraps SWC. When it loads a .ts file, it finds every type annotation, interface, and type-only construct, and replaces those characters with whitespace. Not with nothing — with spaces. Then it runs the result as JavaScript.
That whitespace detail is the elegant part. Because the erased code occupies the same byte positions as the original, line and column numbers are unchanged. A stack trace points at the right line in your .ts file without needing a source map. For a runtime feature added this late, that is a genuinely clean design.
// what you wrote
interface Payment {
id: string;
amountTiyin: number;
}
export function isSettled(p: Payment): boolean {
return p.amountTiyin > 0;
}// what Node executes (whitespace preserved, shown here compressed)
export function isSettled(p) {
return p.amountTiyin > 0;
}The interface is gone. The : Payment and : boolean are gone. Nothing was checked while this happened — and that is the whole point of the next section.
Node does not type-check anything
This is the part people miss, and it matters more than everything else in this post.
Type stripping removes types. It does not verify them. node server.ts will happily run a file that tsc would reject with forty errors. If you assign a string to a number, Node does not care; it deleted the annotation that said so before it ever looked at the code.
Which means: native TypeScript support removes the build step, not the type checker. You still need tsc --noEmit in CI. If deleting your build script also deletes the only place types were ever verified, you have not simplified your pipeline — you have quietly turned TypeScript into documentation.
I keep it explicit in package.json:
{
"scripts": {
"dev": "node --watch src/main.ts",
"start": "node src/main.ts",
"typecheck": "tsc --noEmit"
}
}typecheck runs in CI and in the pre-push hook. The runtime got simpler; the guarantee did not move.
What does not survive stripping
Erasure only works on syntax that can be deleted without changing runtime behaviour. Several TypeScript features fail that test, because they emit code:
enum— a real object exists at runtime, so there is nothing to erase.- Parameter properties —
constructor(private repo: Repo) {}implicitly assignsthis.repo. That assignment is behaviour. - Namespaces with runtime values.
- Decorators — they call functions at class definition time.
The last one is the one that decides things for a lot of backend code. If your service is NestJS, decorators are not a stylistic choice, they are the framework: @Injectable(), @Controller(), @Get(). Type stripping cannot run it.
And here the distinction is sharper than it first looks. --experimental-transform-types handles the first three items on that list — it transforms enums, namespaces, and parameter properties instead of merely erasing them. It does not handle decorators. Decorators are still a TC39 stage 3 proposal, so Node parses them and errors out rather than transforming them; there is no flag that turns them on. For a decorator-based framework you need a real compiler — tsc, SWC, esbuild — or the framework's own build. Not a newer Node.
This is not a criticism of NestJS or of Node. It is a boundary, and knowing where it sits saves an afternoon.
The practical replacements are mechanical:
// instead of: enum PaymentStatus { Pending = 'pending', Settled = 'settled' }
const PaymentStatus = {
Pending: 'pending',
Settled: 'settled',
} as const;
type PaymentStatus = (typeof PaymentStatus)[keyof typeof PaymentStatus];I actually prefer this version independent of the runtime question — it produces a plain object with no TypeScript-specific emit semantics, and the union type is derived rather than declared twice.
The import extension trap
The other thing that catches people: in ESM, you import the file you are actually loading.
import { isSettled } from './payments.ts'; // correct under native execution
import { isSettled } from './payments.js'; // correct when tsc emits JSBoth are legitimate; they describe different worlds. If you run .ts directly, ./payments.js does not exist. If you compile first, ./payments.ts does not exist in the output. Mixing the two conventions across a codebase is a reliable way to produce a module resolution error that reads like a typo.
Pick one per project and configure tsconfig.json to match — allowImportingTsExtensions with rewriteRelativeImportExtensions if you are going native.
Where I have actually adopted it
My honest split, for backend work:
Yes, immediately: scripts, migrations, seeders, one-off operational tooling, small services. Anything where the build step was pure overhead relative to the amount of code. Deleting tsx from a maintenance script's dependency list is a small, real win, and these files rarely use decorators.
Not yet: the main NestJS service. Decorators rule it out today, and even if they did not, the build already does other things — bundling, asset copying, environment injection. Removing one stage of a pipeline that still has four stages is not a simplification, it is a rename.
The general rule I use: native execution is a great default for code you run, and a poor default for code you ship as an artifact. Development, scripts, tooling — run the source. Production containers where you want a reproducible, minimal, auditable output — still build.
The part worth being skeptical about
The framing you will see is "the end of build steps." I do not believe that, and I think the reason is worth naming.
Build steps rarely exist only to convert TypeScript. They exist because someone needed dead-code elimination, or a single-file bundle for a slim container, or to inline a version string, or to strip development-only branches. Type conversion was one job among several — often the least interesting one. Removing it removes a reason to have a build, not the build.
What has genuinely changed is the floor. Starting a TypeScript project no longer requires a toolchain decision on day one. node index.ts runs. For someone learning, or for a script that will never grow past 200 lines, that is a meaningful reduction in ceremony — and ceremony reduction is underrated.
Just keep tsc --noEmit where you can see it.
Sources: Node.js 24 release notes; Node.js documentation on type stripping and the amaro module.
Filed under


