January 7, 2025·6 min read·engineeringtypescriptnodejsruntimetooling

Node 23.6 runs erasable TypeScript by default

Node.js 23.6 executes a restricted TypeScript subset without an opt-in flag. Whitespace-preserving type erasure removes a transformation step while leaving checking and module semantics as separate responsibilities.

A TypeScript annotation can disappear without changing the JavaScript around it. An enum cannot, because its declaration needs JavaScript behavior generated on its behalf. That difference defines the scope of Node's built-in TypeScript execution, and it is more useful than the general claim that Node now runs TypeScript.

Node.js 23.6.0, released January 7, enables type stripping by default. A supported file can run through node file.ts without an explicit opt-in flag. The capability remains experimental. The change removes a command-line requirement, not the distinction between erasing annotations and implementing every TypeScript construct.

I remain ambivalent about TypeScript, but its ecosystem is a strong reason to use it for web applications. A smaller path from source to execution is appealing when it preserves the language and module semantics the program actually uses. The details matter because many projects rely on more than removable annotations.

Erasure leaves JavaScript behavior in place

The documentation for Node 23.6 describes replacing inline type syntax with whitespace. It performs no type checking. The resulting JavaScript executes under the runtime's ordinary semantics.

A self-contained file can therefore use annotations and interfaces without a separate emitted JavaScript artifact. The declarations describe an object that already has an ordinary JavaScript representation.

interface Item {
  name: string
  count: number
}

const item: Item = { name: "widgets", count: 4 }
console.log(item.count)

The interface supplies static information, and the variable annotation constrains the intended type for a checker. Neither needs a runtime object. Removing those parts leaves an ordinary object literal and property access.

That explains both the convenience and the limitation. Node only needs enough syntax knowledge to identify the removable parts. It does not need to determine whether every assignment is compatible with the declared type.

A file can consequently execute even when a type checker would reject it. Successful execution establishes something about the runtime path that was exercised. It is not evidence that the program satisfies its declared static contracts.

The separation can be useful in a development workflow. Checking can remain an explicit compiler or editor operation while execution uses the source directly. It also makes responsibility clear when a command succeeds without having checked anything.

Some TypeScript syntax creates runtime code

The same documentation lists enums, namespaces, legacy module syntax, and parameter properties among constructs that require transformation. Default stripping does not implement that transformation. Node offers a separate experimental transform-types flag for syntax in that category.

A constructor parameter property illustrates the difference between an annotation and generated behavior. The declaration below gives its argument an additional role as an instance property.

class Counter {
  constructor(public value: number) {}
}

The declaration asks TypeScript to create an instance property as well as accept a constructor argument. Erasing public and the type annotation would lose the property initialization. A transformation must insert behavior.

An erasable version states the assignment directly. The property declaration supplies the static description, while the constructor body performs the operation required at runtime.

class Counter {
  value: number

  constructor(value: number) {
    this.value = value
  }
}

Here the runtime behavior is already present as JavaScript. Removing the type declarations preserves the assignment. The extra source text makes the boundary between static description and execution visible.

The important question is therefore what code generation the source assumes. A project with only removable syntax can fit the lightweight execution path. A project using TypeScript to synthesize runtime operations needs a different path or explicit source changes.

This is also why broad claims about native support can mislead maintainers. Support for file extensions, type erasure, and the entire compiler's output semantics are different capabilities. Node's documentation gives the subset an explicit definition.

Whitespace preserves source positions

Replacing type syntax with whitespace preserves the positions of the remaining executable code. Node can therefore report useful stack-trace locations without generating source maps for the stripping-only path. The original line structure remains available.

A transformer that inserts or rewrites executable code has a different problem. The generated program can have positions that do not correspond directly to the source. Node's transform-types mode enables source maps by default for that case.

This distinction has practical value for small scripts. A developer can inspect the TypeScript file that executed, without also locating an emitted file and the mapping between them. The runtime has still processed the source, but its processing deliberately avoids moving the executable parts around.

The design trades broader syntax support for a more constrained transformation. That is a reasonable option when the program does not need the omitted features. It should remain an explicit choice about source requirements.

Module behavior stays with Node

The versioned module rules say Node does not convert between CommonJS and ECMAScript modules. A file's executable imports and exports have to match the intended module system.

A .mts file is always an ES module, while .cts is always CommonJS. A .ts file follows the corresponding .js rules, including the nearest package configuration. The .tsx extension is unsupported by this built-in mechanism.

Relative imports must include file extensions. The source should refer to the TypeScript file that exists, such as ./item.ts. Node does not reinterpret an extensionless specifier according to the compiler's historical resolution conventions.

Type-only imports must also be marked explicitly. In the following pair, one declaration names metadata and the other imports the callable value that the running program needs.

import type { Item } from "./item.ts"
import { loadItem } from "./loader.ts"

The first import disappears. The second remains a runtime dependency. Without the type marker, the runtime may try to import a name that has no exported JavaScript value.

This makes the import statement part of the execution contract. A compiler that analyzes the whole program may infer that an import is only used as a type and remove it. A local stripping pass needs the source to provide that distinction.

Compiler configuration still has a purpose

Node does not read tsconfig.json for this execution path. Options that depend on compiler transformations, such as path aliases or downleveling newer JavaScript, therefore do not acquire runtime effects merely because they appear in configuration.

That does not make the configuration irrelevant to the project. It still describes the separate checking or emitting operation performed by TypeScript. The useful arrangement is for that operation to model the environment in which Node executes the source.

The Node 23.6 documentation recommends TypeScript 5.7 or newer and settings including module: "nodenext", verbatimModuleSyntax, and support for TypeScript extensions in import specifiers. It also includes relative import-extension rewriting for a compiler emission path.

Those options address a relationship between two tools. The checker needs to understand the imports that the runtime will execute. If the project also emits JavaScript, the emitted module specifiers must identify the corresponding output files.

My preference is to keep that relationship explicit. TypeScript's ecosystem can produce excellent web applications when its framework and module conventions are followed carefully. A runtime that ignores compiler configuration makes undocumented assumptions about those conventions more likely to become visible.

Direct execution does not redefine package distribution

Node 23.6 refuses built-in stripping for TypeScript files under node_modules by default. The documentation explains that this discourages publishing packages whose consumers must depend on the runtime processing their TypeScript source.

The documentation points to third-party tooling for full TypeScript support, using tsx as an example. That is an alternative when source requirements exceed the built-in subset. The runtime does not claim that every existing TypeScript project can dispense with its loader or compiler pipeline.

Choosing between these arrangements can remain local to the program. A small maintenance script and a browser application need not use identical execution machinery just because both contain TypeScript. The relevant inputs are the syntax used, the module system expected, and the transformations required before the intended runtime can execute the code.

Direct execution of application-owned source and distribution of a reusable package therefore remain different questions. A script can benefit from source execution without requiring every dependency to adopt the same approach. The boundary limits how far the runtime feature changes existing packaging expectations.

The release also extends TypeScript handling in selected non-file inputs, including evaluation and standard input. It does not support TypeScript syntax in every interactive or inspection interface. Those limits reinforce the need to identify the actual execution mode when describing support.

Node 23.6 makes a useful restricted path the default while retaining its experimental designation. Programs whose TypeScript syntax can be removed locally can execute without a separate emit step. Type checking, module resolution expectations, and any transformation that creates JavaScript behavior remain explicit responsibilities of the rest of the toolchain.