Concept
The beginner framing: running a TypeScript file used to always mean a build step first, tsc to compile, then run the output .js, or a tool like ts-node bridging the gap. That's no longer strictly required for a meaningful range of .ts files.
The precise mental model: Node can run .ts files directly via type stripping, literally removing type annotations and TypeScript-only syntax from the source before executing the remaining plain JavaScript. Confirmed by actually running a .ts file against this app's installed Node v23.11.0:
node greet.ts(node:28197) ExperimentalWarning: Type Stripping is an experimental feature and might change at any timeThe feature works, it's on by default, but the warning is honest: it's still flagged experimental (--experimental-strip-types, controllable via --no-experimental-strip-types to disable).
Critical distinction: stripping is NOT type-checking
// bad-types.ts
function add(a: number, b: number): number {
return a + b;
}
console.log(add("not", "numbers")); // a REAL type error, wrong argument typesnode bad-types.ts
# runs WITHOUT complaint, logs "notnumbers", string concatenation happened,
# because type annotations were simply STRIPPED, never actually CHECKEDThis is the single most important thing to understand about native execution: type stripping removes the : number annotations and runs whatever plain JavaScript remains, it never verifies the annotations were honored. A file can be full of type errors and still execute without a single warning under node file.ts. Actual type-checking still requires running the TypeScript compiler separately:
npx tsc --noEmit bad-types.ts
# THIS is what actually catches the type error, a genuinely different tool, different jobErasable syntax only, enums and namespaces genuinely fail
// colors.ts
enum Color { Red, Green }
console.log(Color.Red);node colors.tsSyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]:
x TypeScript enum is not supported in strip-only modeConfirmed directly against this runtime: enum and namespace declarations throw ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, not because Node forgot to support them, but because they aren't purely erasable, unlike a type annotation (which contributes nothing to the runtime behavior and can simply be deleted), an enum actually generates real runtime code (an object mapping names to values). Stripping can't produce that; it can only erase syntax that has zero runtime effect. A separate, still-experimental flag, --experimental-transform-types, extends beyond pure stripping to handle these cases by actually transforming (not just erasing) the syntax, but plain type-stripping mode, the default, does not.
// this DOES work under plain type-stripping, genuinely erasable:
interface User { name: string; age: number } // erased entirely, zero runtime code
function greet(user: User): string { // annotations erased, function body kept
return `hi ${user.name}, age ${user.age}`;
}
console.log(greet({ name: "Ada", age: 30 })); // runs fineTry It
Predict the outcome before checking the solution.
// mixed.ts
interface Config { port: number }
const config: Config = { port: "3000" }; // WRONG type, string, not number
console.log(`Listening on port ${config.port}`);Run with node mixed.ts. Does it throw a type error, run with a warning, or run silently?
Solution
It runs silently (aside from the standard experimental-feature warning) and logs "Listening on port 3000", the string "3000" gets interpolated exactly as given. The interface Config declaration and the : Config annotation are both purely erasable (they contribute nothing to runtime behavior), so they're stripped away entirely, and the resulting plain JavaScript, const config = { port: "3000" }, has no way to know a number was expected. This is exactly why node file.ts is not a substitute for tsc --noEmit in a real workflow.
Implement It Yourself
Build a small script demonstrating the correct two-step workflow, native execution for quick iteration, tsc --noEmit as the actual safety check:
// package.json
{
"scripts": {
"dev": "node --watch app.ts",
"typecheck": "tsc --noEmit",
"check": "npm run typecheck && node app.ts"
}
}npm run dev # fast iteration, native execution, --watch restarts on change
npm run typecheck # the ACTUAL type safety check, run before commit/in CI
npm run check # both, in the correct order, type-check FIRST, then runThis captures the realistic pattern: native execution's speed is genuinely useful for local iteration, but a real project's CI (or pre-commit hook) still needs tsc --noEmit as the actual type-safety gate, the two tools solve different problems and neither replaces the other.
Under the Hood
Type stripping only works because .ts files are still ultimately loaded through Node's module system covered in Modules & File System, the stripping step happens as part of module loading, before the resulting JavaScript is evaluated, the same load pipeline require(esm) interop (also covered there) hooks into. Managing whether a project's package.json and dependencies expect native execution or a build step is part of Packages, npm & Publishing.
Common Mistakes
1. Treating node file.ts running without error as proof the types are correct
node app.ts # exits cleanly, does NOT mean the code is type-safeA clean run under native execution only means the stripped JavaScript didn't throw a RUNTIME error, it says nothing about whether the original type annotations were actually honored.
2. Using enums or namespaces and being surprised by ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
enum Status { Active, Inactive } // ❌ throws under plain type-strippingThese require actual code transformation, not mere erasure, either avoid them in files meant for native execution (use a union of string literals instead of enum, and plain modules instead of namespace), or use --experimental-transform-types if the transformation is genuinely needed.
3. Skipping tsc --noEmit in CI because "it runs fine locally with node file.ts"
# CI config with no typecheck step, relying only on `node app.ts` succeedingThis provides essentially no type-safety guarantee at all, a CI pipeline for a TypeScript project should still run tsc --noEmit (or an equivalent) as its actual type-checking gate.
Best Practices
- Use native
.tsexecution for fast local iteration (scripts, quick prototypes,--watch-driven dev loops) where the speed of skipping a build step is valuable. - Always run
tsc --noEmitseparately (locally before committing, and in CI) as the actual type-safety check, never treat a clean native run as equivalent to a clean type-check. - Prefer string-literal unions over
enum, and plain ES modules overnamespace, in code intended to run natively, both are genuinely erasable and avoid theERR_UNSUPPORTED_TYPESCRIPT_SYNTAXfailure entirely. - Don't assume
.tssupport means a bundler/build step is never needed, for browser-targeted code, a build step is still required regardless of Node's native execution capability; this feature is specifically about running directly under Node.
Performance Tips
- Native execution skips an entire separate compile step for local iteration, which is a genuine startup-latency win for scripts and dev loops, but it does NOT skip type-checking cost, since type-checking never ran in the first place;
tsc --noEmitstill has its own separate cost, run when it's actually needed (pre-commit/CI), not on every single script invocation. - Since type stripping is (as the name implies) just deleting syntax rather than doing meaningful transformation work for the common erasable case, its runtime overhead versus running the equivalent hand-written JavaScript directly is minimal.
