Home / Articles / What Node.js Native TypeScript Support Actually Does and Doesn't Do

This article is published in English.

What Node.js Native TypeScript Support Actually Does and Doesn't Do

This article explains how Node.js runs .ts files natively via type stripping, why it skips type checking, and when you still need a real build step.

1595 words

You know the drill by now. You spin up a new TypeScript project, write your first .ts file, try to execute it, and immediately remember there's a whole setup ritual you have to complete first: pull in ts-node or tsx, configure a tsconfig.json, maybe wire up a build script, and figure out whether you're targeting CommonJS or ESM. None of that is particularly difficult on its own. It's just friction that piles up before you've written any actual application logic, and it happens every single time you start something new.

This year, for a substantial portion of real-world Node.js projects, that entire ritual has simply disappeared. Typing node file.ts just works. No flags, no extra dependencies, no config files. The change rolled out quietly, without any big announcement, but it's the kind of small friction removal that you'd otherwise hit repeatedly throughout the week — and that adds up to something genuinely worth digging into.

What’s Actually Happening

The underlying mechanism is called type stripping, and the name is refreshingly literal: Node.js parses your TypeScript source, removes the type annotations, and executes whatever plain JavaScript remains. That's the entire concept.

// Before: what you write
interface User {
  name: string;
  age: number;
}
function describeUser(user: User): string {
  return `${user.name} is ${user.age} years old`;
}
// After: what Node.js actually executes, post-stripping
// (whitespace preserved, so line numbers stay accurate for debugging)
function describeUser(user) {
  return `${user.name} is ${user.age} years old`;
}

The interface declaration vanishes completely. Annotations like : User and : string get erased. What remains is ordinary, valid JavaScript that V8 executes just as it always has — there's no special runtime involved, no polyfills, nothing new happening conceptually at execution time.

Behind the scenes, this process runs through a library named Amaro, which is a lightweight wrapper around @swc/wasm-typescript, a WebAssembly compilation of the TypeScript parser that SWC built in Rust. The speed here doesn't come from some clever optimization; it comes from doing considerably less work than a full compiler would. It doesn't resolve types across multiple files, doesn't verify that your annotations are correct, and doesn't produce declaration files. It simply parses the syntax tree, strips out the TypeScript-only pieces, and returns JavaScript. That narrow scope is precisely why it's fast.

Node's support for this feature moved through a few phases before reaching its current form: experimental support for straightforward stripping landed in v22.6.0, a separate flag for handling more complex constructs like enums appeared in v22.7.0, and the whole feature became stable by default in both v22.18.0 and v24.3.0 — meaning no flags are needed at all for code that falls within the supported syntax. Notably, Node later dropped that enum-specific flag altogether, choosing to commit to a deliberately narrow and predictable scope rather than attempting to support the full language.

The Honest Limits

Here's the crucial part to understand if you're going to depend on this feature, and it deserves to be stated plainly: type stripping is not the same thing as type checking.

Removing a type annotation doesn't first confirm it's correct — it just deletes it. So a file containing a real type error, say, passing a string somewhere a number was expected, will run without any complaint under type stripping, since by the time the code actually executes, the type information that would have flagged the problem is already gone. Every serious source on this topic agrees on the same advice: continue running tsc --noEmit as its own step in your CI pipeline. Type stripping takes the place of your build step, not the compiler's job of actually catching mistakes.

The more consequential restriction is around exactly which TypeScript syntax is even eligible for stripping. Node only supports what's known as erasable syntax — language constructs that can be removed entirely without altering what the code does when it runs. A meaningful portion of TypeScript doesn't meet that bar, because it produces genuine runtime behavior that can't just be deleted:

// ❌ Fails under type stripping — enums generate a real runtime object
enum Direction {
  Up,
  Down,
  Left,
  Right,
}
// ❌ Fails - parameter properties generate constructor assignment code
class Point {
  constructor(public x: number, public y: number) {}
}
// ❌ Fails - this is a CommonJS-style module alias, not an erasable type
import fs = require('fs');
// ❌ Fails - angle-bracket type assertions look like real syntax to strip,
// but the parser can't tell it apart from JSX safely
const num = <number>someValue;

Each of these constructs triggers a hard failure rather than a silently wrong build — Node's stripping mechanism is deliberately built to stop and complain instead of guessing at what you meant. Legacy-style decorators, enabled through the old experimentalDecorators flag, hit the same wall for the same reason. The newer, standards-track decorators from TC39 are a different story: they're specified in a way that compiles down to ordinary JavaScript syntax, so there's nothing special left to strip away, and they run under Node without any trouble.

How TypeScript Adapted

Rather than let developers stumble onto these boundaries one file at a time while running code, the TypeScript team moved quickly to make the rules explicit. TypeScript 5.8 shipped a new compiler flag, --erasableSyntaxOnly, which makes tsc itself reject any of the non-erasable patterns described above during compilation. That changes the question of "will Node actually run this" from something you discover the hard way at runtime into a rule you can enforce up front, as an explicit constraint on the whole codebase.

// tsconfig.json
{
  "compilerOptions": {
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true  // pairs well with this —
                                   // keeps type-only imports explicit
  }
}

Turning this option on is worth doing even if you have no plans yet to remove your build step, simply because it gives you a definitive, automated answer to whether your code qualifies — instead of finding out piecemeal as things break.

The amount of migration work this uncovers varies a lot depending on what you're starting from. A newly created backend service or command-line tool can usually turn on erasableSyntaxOnly right away with little or nothing to fix. A codebase that relies heavily on enum declarations, or one built on a framework that assumes legacy decorators — older configurations of NestJS or TypeORM are the examples that come up most often — is looking at real rewrite effort, or a conscious choice to stick with a traditional build pipeline rather than migrate everything in one go. The most reliable way to size up the work before committing to anything is to flip on erasableSyntaxOnly, run tsc --noEmit once, and just look at how many errors come back. That single run tells you the actual scope of the problem before you touch any runtime configuration.

Practical Guidance

Across teams that have already gone through this transition, a fairly consistent decision framework has emerged.

Drop the build step for backend services, command-line tools, internal utilities, and standalone scripts — anything that runs straight under Node and isn't published as a package for others to consume. This is exactly the case type stripping was built for, and services built on Express or Fastify are commonly reported to work with it immediately, with no code changes needed.

Keep a build step for anything that runs in a browser, since browsers can't execute .ts files at all — you'll still need a bundler no matter what Node supports on the server. Also keep a build step for any npm package you publish, because the people installing it need compiled JavaScript plus .d.ts declaration files, and you have no guarantee their Node version even supports type stripping. And keep a build step for any codebase still depending on legacy decorators or heavy enum usage that hasn't been converted yet.

Whichever path you take, keep running tsc --noEmit in CI. Dropping the build step only removes the compilation step — it was never meant to remove type checking, and treating it as a replacement for tsc is the one real way this change can quietly cost you safety.

The Actual Takeaway

What makes this shift interesting isn't primarily the speed gain, even though the faster feedback loop is a genuine, immediate benefit. It's a signal about where TypeScript is heading conceptually. For most of its history, TypeScript has been described as a language that compiles into JavaScript — a distinct language, translated before it can be executed. What type stripping in Node quietly suggests is TypeScript drifting toward being treated more like a variant of JavaScript that a runtime can simply read as-is, at least for the broad, everyday subset of the language that most developers actually use. That's not the entire language, and it was never going to be — enums and legacy decorators still have real use cases and aren't disappearing. But for all the code that doesn't need them, the step that used to sit between writing TypeScript and running it has stopped being obligatory, and that's a more significant shift than the modest attention it got would suggest.