You typed node app.ts, it ran, and for a second it felt like the future arrived. Then you added an enum and Node threw a SyntaxError in your face. Native TypeScript in Node is real, but it plays by one rule that explains every surprise: Node strips types, it does not compile them.
Node 24 — the current LTS — runs .ts files directly with type stripping on by default, the feature having been unflagged back in v23.6. There is no tsc, no build folder, no emitted JavaScript. Node parses your file, blanks out the type-only syntax, and executes what is left. This field guide is the map of what survives that process and what detonates.
1. The one mental model: stripping is whitespace replacement
Type stripping does not rewrite your code. It overwrites type annotations with spaces of the same length and runs the rest. const x: number = 1 becomes const x = 1. Interfaces, import type, generic parameters, and return annotations all vanish into blanks.
If a construct can be erased to whitespace and still be valid JavaScript, Node runs it. If it needs to generate runtime code, Node refuses.
That single sentence predicts every item below. Because positions never shift, stack traces and debugger line numbers stay accurate without a source map.
2. What strips clean
These are pure type syntax — gone at runtime, zero cost:
- Type annotations on variables, parameters, and returns
interfaceandtypedeclarationsimport type/export type- Generics:
function f<T>(x: T) - Non-null
!andas/satisfiesassertions
Write them freely. They never reach the JavaScript engine.
3. What blows up (and why)
Four constructs emit real runtime code, so stripping can't handle them and Node throws:
- Enums.
enum Color { Red }compiles to an IIFE that builds an object. No transform, no object. - Namespaces with a runtime body.
namespace N { export const x = 1 }needs an emitted object. - Parameter properties.
constructor(private name: string)secretly generatesthis.name = name. - Experimental legacy decorators that rely on emitted metadata.
The fix for the common cases is boring and good. Replace an enum with a plain object:
const Color = {
Red: "red",
Blue: "blue",
} as const;
type Color = (typeof Color)[keyof typeof Color];And replace parameter properties with explicit assignment:
class User {
name: string;
constructor(name: string) {
this.name = name;
}
}4. Catch the breakage before it runs
Don't discover these at runtime. TypeScript 5.8 added the erasableSyntaxOnly compiler flag that errors on exactly the constructs Node can't strip — enums, namespaces, and parameter properties — at type-check time.
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true
}
}Turn it on and your editor lights up the landmines before you ship them.
5. Imports need the real extension
Node resolves files literally. When you import a local TypeScript file, write the actual .ts extension — not the extensionless or .js path you used in build-step land:
import { add } from "./math.ts";That is why allowImportingTsExtensions belongs in your config. The upside: require() of an ES module is unflagged since v23, so mixed CommonJS/ESM trees mostly just work — with one trap.
6. The async require trap
require() can now pull in an ESM file, but if that file uses top-level await, you get ERR_REQUIRE_ASYNC_MODULE. The module graph can't resolve synchronously. Either drop the top-level await behind an async function, or import() the module instead of requiring it.
7. Remember: Node never type-checks
This is the rule people forget. Type stripping ignores your types — it deletes them. A genuine type error runs anyway and may blow up later as a plain JavaScript fault.
So keep tsc in the loop for verification, even with no emit:
tsc --noEmitRun Node for speed during development; run tsc --noEmit in CI for safety. They are different jobs.
Quick reference
- Run it:
node app.ts(no flag on Node 24) - Strips clean: annotations,
interface,type, generics,as/satisfies - Blows up: enums, runtime namespaces, parameter properties
- Guardrail:
erasableSyntaxOnly: true - Imports: use explicit
.tsextensions - Gotcha: top-level
await+require()→ERR_REQUIRE_ASYNC_MODULE - Still check types:
tsc --noEmitin CI
Native TypeScript deletes the build step for a huge class of projects. You just have to write the erasable subset — and once the model clicks, the surprises stop.