Everything You Need to Know About Node.js Type Stripping

Everything You Need to Know About Node.js Type Stripping

All the technical details and reasoning behind this long anticipated feature.

In August 2024 Node.js introduced a new experimental feature, Type Stripping, aimed at addressing a longstanding challenge in the Node.js ecosystem: running TypeScript with no configuration. Enabled by default in Node.js v23.6.0, this feature is on its way to becoming stable. This article explores the motivations behind this feature, the problems it solves, and its implications for the Node.js community.


Why TypeScript?

TypeScript has reached incredible levels of popularity and has been the most requested feature in all the latest Node.js surveys. Unlike other alternatives such as CoffeeScript or Flow, which never gained similar traction, TypeScript has become a cornerstone of modern development.
While it has been supported in Node.js for some time through loaders, they relied heavily on configuration and user libraries. This reliance led to inconsistencies between different loaders, making them difficult to use interchangeably. The developer experience suffered due to these inconsistencies and the extra setup required.
The motivation behind this feature is to further improve the development experience by speeding up the cycle between writing code and executing it. The goal is to make development faster and simpler, eliminating the overhead of configuration while maintaining the flexibility that developers expect.

All time download count of the typescript npm package. Source: npm trends

The TypeScript Compiler

TypeScript, as a language, is a superset of JavaScript that introduces static typing and other enhancements. However, TypeScript is not just a language, it also relies on a toolchain to implement its features. The primary tool for this purpose is tsc, the TypeScript compiler CLI.
tsc provides two core functionalities: type checking and transpilation.

Type checking is tightly coupled to the implementation of tsc, as there is no formal specification for how the language’s type system should behave. This lack of a specification means that the behavior of tsc is effectively the definition of TypeScript’s type system. tsc does not follow semantic versioning, so even minor updates can introduce changes to type checking that may break existing code.

Transpilation, on the other hand, is a more stable process. It involves converting TypeScript code into JavaScript by removing types, transforming certain syntax constructs, and optionally “downleveling” the JavaScript to allow modern syntax to execute on older JavaScript engines. Unlike type checking, transpilation is less likely to change in breaking ways across versions of tsc. The likelihood of breaking changes is further reduced when we only consider the minimum transpilation needed to make the TypeScript code executable - and exclude downleveling of new JavaScript features not yet available in the JavaScript engine but available in TypeScript.

Why Node.js Cannot Perform Type Checking

Over the years, many options of supporting TypeScript were explored, but they all required additional configuration through flags or configuration files, adding complexity to the developer experience.
One of the proposed solutions was embedding tsc directly into Node.js which at first glance might seem the most obvious but it presents significant challenges.

First, Node.js prioritizes stability. With its semver guarantees and long-term support cycle of nearly three years, Node.js cannot afford the risks associated with frequent updates to tsc. Even minor changes in tsc could lead to breaking changes in type checking, potentially causing production deployments to fail.

Second, type checking is strictly coupled to the tsconfig.json file. The configuration file must align with the specific version of tsc being used, and syntax supported. There is very little value in having Node.js embed a singular default tsconfig.json because it’s very unlikely that it will align with the user project configuration.

Third, the size of the tsc package (approximately 22MB) makes it impractical to embed within Node.js. Including such a large dependency would significantly increase the runtime’s binary size, which is especially problematic for environments with constrained memory, such as serverless functions (AWS Lambda) and other platforms where Node.js runs.

In day-to-day development, the TypeScript Language Server in your IDE already provides real-time type feedback, allowing you to see issues as you code without the need for constant checks during execution.
Instead, Type Checking is usually reserved for CI pipelines or commit hooks without interrupting the fast-paced development process. For this reason, I consider it reasonable to install tsc as a devDependency rather than embedding it in the runtime. This allows you to use the version of tsc and the tsconfig settings you prefer, without being bound by runtime defaults that may not match your development environment.

Type Stripping

While built-in type checking presents significant pitfalls (as discussed earlier), transpilation is feasible, however, not without its own set of quirks.

One key challenge lies in avoiding the complexity of supporting and demanding a tsconfig.json file. The various "flavors" of TypeScript, such as different module resolution strategies and compiler options, must be carefully handled to preserve Node.js compatibility without resorting to behind-the-scenes magic.

To overcome these obstacles, Node.js, before enabling it by default, introduced --experimental-strip-types. This mode allows running TypeScript files by simply stripping inline types without performing type checking or any other code transformation. This minimal technique is known as Type Stripping. By excluding type checking and traditional transpilation, the more unstable aspects of TypeScript, Node.js reduces the risk of instability and mostly sidesteps the need to track minor TypeScript updates. Moreover, this solution does not require any configuration in order to execute code.

Transpilation typically relies on generating and parsing source maps to reverse-engineer code locations.
Source maps are essential because, without them, tracing errors and debugging would be extremely difficult. This is due to the mismatch between the code written by developers and the code being executed after transpilation.
However, this process introduces additional overhead: generating, loading the source maps and reconstructing the stack trace is not free.
Node.js eliminates the need for source maps by replacing the removed syntax with blank spaces, ensuring that the original locations of the code and structure remain intact. It is transparent - the code that runs is the code the author wrote, minus the types.

This technique was inspired by ts-blank-space by Ashley Claymore.

To perform transpilation, Node.js uses a customized version of swc, a fast and lightweight transpiler written in Rust. swc is the most widely used Rust-based JavaScript compiler. It has been battle-tested by webpack, Rollup, Turbopack, rspack, Deno, & other popular projects in the JavaScript ecosystem. The primary author DongYoon Kang directly assisted the effort to use swc in Node.

To allow users to upgrade their transpiler version independently from the one in Node.js, we have created amaro. Amaro is an npm package that wraps swc and acts as both an internal in Node.js and an external loader, enabling users to update it separately from the Node.js runtime.

Tradeoffs

By stripping types, it means the JavaScript output is as similar as possible to the originally authored TypeScript code. This requires making trade-offs in the syntax that Node.js can support, ensuring that the code remains compatible with the runtime while maintaining the benefits of type-checking. These tradeoffs are necessary to preserve performance but they can limit some of the more complex TypeScript features that might not translate directly to JavaScript.

Transformation

Some TypeScript features cannot simply be stripped because they generate JavaScript code that affects the runtime behavior.
These features include enums, instantiated namespaces and parameter properties in classes.
Avoiding these features eliminates reliance on code generation (that may need to change as TypeScript evolves), source maps, and ensures the developer can transparently see the code that will run - which all add up to a significant benefit.

However, for users that need these features, they can always opt-in to the transformation mode by using the flag --experimental-transform-types which automatically implies that Node will enable sourcemaps. By keeping —-experimental-strip-types the default behavior, sourcemaps remain disabled by default. We encourage users to first try modern TypeScript syntax that does not require transformations.

Here’s an example of how TypeScript enum expands via code generation when transpiled into JavaScript:

enum Direction {
    Up = 1,
    Down,
    Left,
    Right
}
var Direction;
(function (Direction) {
    Direction[Direction["Up"] = 1] = "Up";
    Direction[Direction["Down"] = 2] = "Down";
    Direction[Direction["Left"] = 3] = "Left";
    Direction[Direction["Right"] = 4] = "Right";
})(Direction || (Direction = {}));

Polyfills / Downleveling

A special category of syntax that TypeScript users might use and find missing in Node.js are forthcoming JavaScript feature that are on their way to become part of the ECMAScript specification. TypeScript is commonly configured to perform downleveling of JavaScript features. An example is Decorators - a TC39 Stage 3 proposal - that is supported by TypeScript but not yet by V8 (the Node.js JavaScript engine).

The decision to avoid supporting downleveling is driven by minimising the risk of any behavioral deviation between the implementation and the official specification, as well as acknowledging that this feature is mostly orthogonal to the goal of TypeScript support; downleveling could equally apply to regular JavaScript files. Since these not-yet-standardized features are destined to ship natively in JavaScript engines soon, and then become part of the language itself, we cautiously avoid any risk here meaning users will need to wait a bit longer for these features to be supported.

TypeStripping in node_modules

Node.js forbids running .ts files in node_modules. This decision, made in collaboration with the TypeScript team, prevents developers from publishing .ts files on npm, which could lead to significant performance and compatibility issues. In terms of performance, large .ts files in dependencies could cause IDEs to scan unnecessary files, slowing down development workflow compared to the relatively lightweight cost of scanning .d.ts files.
Publishing .ts file would require the consumer to have a compatible tsconfig.json and therefore a compatible tsc version which, remember, does not follow semantic versioning. This could lead to increased compatibility issues.
Instead, developers are encouraged to publish .d.ts files alongside their transpiled JavaScript.
This limitation does not affect monorepos. In monorepos, files are not typically stored directly under node_modules, instead, symlinks are used to link dependencies across different packages within the same repository. Rather than duplicating the actual files in the node_modules directory, a symlink is created, pointing to the relevant package or file elsewhere in the monorepo. Since the check occurs after the symlink is resolved, it does not impact the functionality.

Features that require configuration

Since Node.js does not rely on a tsconfig.json it’s not possible to support features that specifically require one. This is the case for tsx.
tsx files are cannot be supported because their behavior is tightly dependent on the consumer library and the runtime behavior varies based on the specific requirements of each framework. Since Node.js doesn't enforce or depend on a particular frontend library, supporting tsx would not align with the core purpose of Node.js, which focuses on backend runtime functionality rather than frontend-specific libraries.

This simple tsx code:

export default function Test() {
  return (
    <h1>Hello World!</h1>
  )
}

Once transpiled for React it becomes:

export default function Test() {
    return /*#__PURE__*/ React.createElement("h1", null, "Hello World!");
}

While for Vue:

import { createVNode as _createVNode, createTextVNode as _createTextVNode } from "vue";
export default function Test() {
  return _createVNode("h1", null, [_createTextVNode("Hello World!")]);
}

Node.js offers an official tsx loader available on npm, along with a variety of other extensions and use cases. Check nodejs-loaders.

A Predictable Experience

Node.js does not aim to automatically adjust your code just to make it work for the sake of convenience.
It avoids any behind-the-scenes magic, so that developers code remains unchanged and predictable.
Type Stripping makes the code clear and predictable, very close to how developers would write regular JavaScript. For example, when importing TypeScript files, you have to explicitly include the .ts extension.
Without an extension, it becomes unclear which file is being referenced, whether it's a .js or .ts file. If all of these file types exist on the filesystem, ambiguity arises, leading to potential inconsistencies and overhead.
One major benefit is that it makes it easy to migrate old JavaScript codebases to TypeScript, because they can co-exist in the same project as you can import .ts file from a .js file and vice-versa.

Since types are erased at runtime, when importing a type definition, you must use the type keyword:

// THIS IS CORRECT
import { Foo, type FooType } from './foo.ts';
import type { MyType } from './types.ts';

// THIS WILL THROW AN ERROR AT RUNTIME
import { Foo, FooType } from './foo.ts';
import { MyType } from './types.ts';

The type keyword tells the transpiler, you're only importing a type definition, not a value. If you forget to use type, Node.js will treat the import as a standard import, leading to a runtime error because the export doesn’t exist at runtime.
If you use a tsconfig.json, with the flag verbatimModuleSyntax, the type checker will alert you to use the type keyword when needed.

We also recommend using a linter to prevent accidental “side-effect” runtime imports of files that are only needed for their types. For example, by using the @typescript-eslint/no-import-type-side-effects ESLint rule.

Future Direction

The TypeScript team have directly supported this work. For example, in TypeScript 5.7 introduced a new flag rewriteRelativeImportExtensions to rewrite .ts extensions to .js during transpilation. This new flag complements Node.js Type Stripping by allowing to it generate executable JavaScript code that you can publish to npm.

In the next TypeScript version (likely 5.8) a new —-erasableSyntaxOnly flag may be introduced to error on features with runtime emit (enums, namespaces, parameter properties). This will provide a more ergonomic developer experience by surfacing the error in your Editor, rather than waiting for a runtime error in Node.

Joyee Cheung is exploring compile cache for TypeScript files in Node.js. This can speed up subsequent module loads by reusing cached compiled code, significantly improving performance in repeated workflows.

As this experimental feature evolves, the Node.js team will continue collaborating with the TypeScript team and the community to refine its behavior and reduce friction.
You can check the roadmap for practical next steps.

Conclusion

With a large and diverse user base, Node.js must be especially careful not to disrupt an ecosystem that is both intricate and delicate. The process of implementing new features is deliberate and thoughtful, by making sure that solutions are robust and maintainable. While some features may appear trivial to implement at first glance, they often involve complex considerations.

I am grateful for the lessons learned throughout this journey. This is just the beginning, and I look forward to future collaboration, especially with other runtimes, many of which have already supported this feature in different ways. Their experiences and implementations have provided valuable insights that will help shape the direction of Node.js moving forward.

A special thanks ⭐ to the entire nodejs/typescript team for their contributions to this effort, and to Rob Palmer, Jessica Sachs and Jordan Harband for reviewing this article.

This work was done as a volunteer. If you’ve benefited from it, please consider donating to support further development.