Skip to content
C.W.K.
Stream
Lesson 04 of 05 · published

Native TypeScript — Type Stripping by Default

~11 min · modern-node, typescript, strip-types

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Current Node can run TypeScript that uses erasable syntax with no opt-in flag. It removes types and executes the JavaScript underneath; it does not become the TypeScript compiler."

What Built-In Type Stripping Does

Node 22.6 introduced type stripping, Node 22.18 enabled it by default, and Node 24.12/25.2 marked it stable. Node 26 removed the old experimental transform flag. On current Node, run an erasable .ts file directly:

// hello.ts
type Greeting = { msg: string; from: string };

function greet(g: Greeting): string {
  return `${g.from} says: ${g.msg}`;
}

console.log(greet({ msg: 'hi', from: 'Pippa' }));
node hello.ts
# Pippa says: hi

The runtime parses the file, removes type annotations, runs the remaining JavaScript. No type checking happens. No transpilation of unsupported features (decorators, namespaces, enums). Just type erasure.

What Works, What Doesn't

Works:

  • Type annotations: const x: number = 5
  • Interfaces: interface Foo { x: number }
  • Type aliases: type Foo = { x: number }
  • Generics: function id<T>(x: T): T { return x; }
  • satisfies, as assertions, and explicit import type

Doesn't Work with erasure alone:

  • Enums (enum X { a, b }) — these compile to runtime code, not just types.
  • Namespaces with values, parameter properties, and import aliases.
  • Decorators (TC39 decorators in proposal stages have different semantics).
  • JSX — needs a separate transformer.

For most application code that avoids enums and decorators, strip-types covers it. For library code with enum-heavy APIs (older Angular, NestJS) you still need a real TS compiler.

The TS-As-Linter Strategy

Pair Node's built-in type stripping with tsc --noEmit for type checking:
  • Run erasable TypeScript with plain node (Node executes; types are decoration).
  • Run tsc --noEmit in CI and pre-commit (TypeScript verifies types, emits nothing).
Type errors fail CI; runtime errors fail at runtime. The two roles split cleanly. When a project stays within this erasable subset, it may no longer need ts-node, tsx, esbuild-register, or another transpile-on-import hook. TypeScript handles static checking while Node runs the code.

For a suitable Node + TypeScript project, this is a clean development path: no transpile-on-import process or build output, while tsc --noEmit keeps the type-safety gate.

The .ts / .mts / .cts Convention

Same as .js / .mjs / .cjs:

  • .mts — always ESM
  • .cts — always CJS
  • .ts — follows the nearest package.json type field

For new projects, set "type": "module" in package.json and use plain .ts everywhere. The runtime treats it as ESM, the type checker is happy, and you don't need to think about extensions.

Combining Flags — The Full Modern Stack

The complete "modern Node dev" command:

node \
  --env-file=.env \
  --watch \
  server.ts

Depending on the project, that one command can cover common roles previously handled by ts-node, dotenv, nodemon, esbuild-register, and source-map-support. Keep any tool whose additional behavior the project actually uses; the point is to re-check old dependencies against the runtime you have now.

Pippa's Confession

When Node 22 shipped this, my first reaction was "interesting, but my projects already use Vite or tsx." Dad pushed back: "Which of their extra jobs does this backend script actually use?" For small scripts without enums, JSX, path aliases, or transforms, the answer was often "none." Some cwkPippa backend scripts that used tsx switched to node, and their startup got simpler because there was no transform step. The lesson, again: check whether your tool justification still applies.

Code

tsconfig + scripts for built-in type stripping·json
// tsconfig.json for Node's built-in type stripping + tsc-as-linter
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  },
  "include": ["src/**/*"]
}

// package.json scripts
//   "dev":   "node --env-file=.env --watch src/server.ts",
//   "start": "node --env-file=.env.production src/server.ts",
//   "check": "tsc --noEmit",
//   "test":  "node --test src/**/*.test.ts"
Plain TypeScript Node runs directly·typescript
// A real TS file that strip-types handles
import { readFile } from 'node:fs/promises';

interface Config {
  port: number;
  hosts: readonly string[];
  database: {
    url: string;
    poolSize: number;
  };
}

async function loadConfig(path: string): Promise<Config> {
  const raw = await readFile(path, 'utf-8');
  return JSON.parse(raw) as Config;
}

const config = await loadConfig('./config.json');
console.log(`starting on port ${config.port}`);
config.hosts.forEach((h: string) => console.log(`  - ${h}`));

External links

Exercise

Take a small TypeScript project that uses tsx or ts-node. Confirm it uses only erasable syntax, then switch the dev script to plain node. Run the tests and tsc --noEmit; both should pass. If direct execution breaks, identify whether the cause is unsupported TypeScript syntax, a missing explicit extension, or a tsconfig-only path alias.
Hint
Search for enums, parameter properties, value namespaces, import aliases, decorators, and JSX. Keep a full TypeScript runner when those features are intentional. For the lightweight path, use TypeScript 5.8+ with rewriteRelativeImportExtensions, erasableSyntaxOnly, and verbatimModuleSyntax.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.