TypeScript and Modern Node.js: Resolving the ESM Friction

Programming

As modern Node.js embraces native ECMAScript modules (ESM) and TypeScript type stripping, a significant friction point emerges. This article examines how TypeScript's current module resolution and build assumptions clash with these advancements, leading to complex configurations and hindering a seamless developer experience. It advocates for TypeScript's evolution to align with contemporary JavaScript ecosystem standards.

Node.js has rapidly evolved, often outpacing the development tools designed to support it. Since Node 23.6, released in January 2025, the runtime natively supports type stripping for .ts files by default. Modern Node.js releases also strictly adhere to ECMAScript Module (ESM) semantics, demanding explicit file extensions and supporting import maps. This allows developers to write modern TypeScript or JavaScript and execute it directly, bypassing the need for complex bundlers or intricate transform pipelines.

This streamlined model works because Node.js rigorously follows ECMAScript specifications for module behavior. Imports function precisely as defined by the language, and modules load as developers expect. Runtimes no longer require layers of compatibility tooling to bridge gaps in the platform's native capabilities.

However, TypeScript has not fully kept pace with this evolving environment. This misalignment creates noticeable friction in projects attempting to run or publish modern ESM code while leveraging TypeScript for type checking.

ECMAScript as Node.js's Foundational Standard

The ECMAScript specification dictates how modules import one another and mandates the use of explicit file extensions. Browsers, Node.js, and other runtimes like Deno and Bun (which follow these rules even more strictly) all adhere to these guidelines. This establishes a uniform direction across the entire JavaScript ecosystem.

Node.js's native support for .ts files integrates TypeScript directly into this world. With the runtime capable of stripping types directly, .ts ceases to be a specialized intermediate format. Instead, it becomes a variation of JavaScript that the platform itself can interpret. Effectively, ECMAScript, through Node.js, now acts as a primary consumer of TypeScript syntax.

This is a crucial shift: TypeScript is no longer merely compiling for a hypothetical downstream toolchain; it is compiling for an ECMAScript runtime that enforces modern rules.

TypeScript's Adherence to Pre-ESM Assumptions

Despite the ecosystem's evolution, TypeScript continues to operate based on older assumptions regarding import syntax and module resolution. It still treats explicit extensions as unusual, anticipates extensionless imports that no longer reflect contemporary module mechanisms, and disregards Node.js's import map resolution.

A simple example highlights this discrepancy:

// src/a.ts
export function a() {}
// src/b.ts
import { a } from "./a.ts"

While Node.js readily accepts this syntax, TypeScript will flag it unless specific configuration options are enabled. There is no conflict with the ECMAScript specification itself; the conflict lies solely with TypeScript’s historical module model.

Build Process Complications

This friction intensifies significantly when preparing JavaScript for distribution. The intuitive expectation is straightforward: strip types, place the output in a dist directory, and update import paths to point to .js files.

Instead, developers frequently find themselves needing two tsconfig files – one for development and another for builds. The build configuration becomes necessary not due to unique project requirements, but because TypeScript demands explicit correction to align its behavior with the runtime it targets.

Consider a trimmed tsconfig example:

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "outDir": "dist",
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true
  }
}

These settings do not enable advanced functionality; rather, they facilitate standard, expected behavior. Without them, the emitted JavaScript would contain incorrect module specifiers, rendering it unexecutable in Node.js without additional tooling.

The problem becomes even more apparent with declaration files. TypeScript emits .d.ts files that reference .ts modules within the output directory, even though only .js files are present. This only "works" because TypeScript itself is the sole consumer of declaration files and quietly tolerates these invalid specifiers. The runtime, however, does not. When similar patterns appear in emitted JavaScript, they inevitably lead to failures.

TypeScript rejects imports that are valid under the ECMAScript specification and produces artifacts that do not satisfy the runtime's rules. While this behavior is internally consistent within TypeScript, its underlying assumptions no longer align with the environment where the code is executed. This gap represents a significant inconvenience for developers.

An Outdated Philosophical Stance

TypeScript has long maintained that its primary role is type checking, and therefore it should not be responsible for rewriting import paths or resolving module aliases. Historically, this stance was justifiable; the ecosystem relied heavily on bundlers, loaders, and layered build pipelines, and TypeScript was not expected to produce runnable JavaScript independently.

However, the environment has changed dramatically. Node.js now directly executes .ts files, enforces ECMAScript rules, and supports import maps. Many projects operate entirely without bundlers. When the runtime evolves, the tools that target it must also adapt. Failure to do so burdens developers with increased configuration, extra documentation, and unnecessary complexity where processes should be straightforward.

This is not a theoretical grievance; it is a daily source of friction for engineers aiming to publish libraries to npm or maintain lean, non-bundled backend services.

The Cost of Mismatch

The consequences of this misalignment are evident throughout the development process:

  • Publishing an npm package often necessitates two tsconfig files for scenarios that should be simple.
  • Developers must manually manage import extensions that the runtime already understands how to interpret.
  • Import maps, now standardized and supported by modern JavaScript runtimes, do not function reliably within TypeScript's build pipeline.
  • Output artifacts frequently contain references to paths that do not exist in the distribution.
  • Modern ESM workflows, which ought to be the simplest approach, instead become the most fragile.

These issues do not arise from the act of adding types to JavaScript; they stem specifically from how TypeScript chooses to interact with modern ECMAScript runtimes.

Towards Reasonable Defaults

The concern about breaking existing projects is valid. TypeScript has built a strong reputation for stability, and projects relying on older assumptions should not be forcibly migrated.

A pragmatic path forward would involve:

  • Adopting modern defaults only when a project explicitly opts into an ECMAScript-aligned mode, such as "module": "nodenext", or a dedicated modern target.
  • Allowing .ts extensions in imports within this mode without warnings or requiring additional flags.
  • Rewriting .ts to .js in the emitted output when targeting Node.js ESM.
  • Generating .d.ts files that accurately reference the actual output files (e.g., .js files).
  • Acknowledging and supporting import maps instead of producing code that renders them inoperative.

Implementing these changes would not disrupt established projects but would significantly enhance the developer experience for the growing number of projects built for modern ECMAScript runtimes.

A Wider Ecosystem Perspective

Other runtimes, including Deno and Bun, already adhere closely to ECMAScript semantics. Their existence demonstrates that a predictable, spec-aligned module system is not only achievable but also highly practical. The pressure for alignment is not localized to one environment; it represents the pervasive direction of the entire JavaScript ecosystem.

When the runtime and the compiler fundamentally disagree on module semantics, this friction manifests in every import statement, every configuration file, and every build artifact.

Conclusion

TypeScript has delivered immense value to the industry, significantly elevating the quality and maintainability of JavaScript codebases. This contribution remains undisputed. However, the underlying platform is undergoing a profound transformation. Writing modern JavaScript should be the default, seamless experience, not one encumbered by complex configurations and constant workarounds.

Node.js is now a highly capable ECMAScript runtime with clearly defined semantics. As the runtime converges with the language specification, the onus shifts to the surrounding tooling. TypeScript's future success lies not in resisting this convergence, but in aligning with it so seamlessly that the distinction between the two fades entirely.