tezvyn:

ES Modules in TypeScript: The Compile-Time Contract

AI-drafted, machine-checkedintermediate

TypeScript ES modules are a compile-time target, not a runtime guarantee. Configure this when targeting modern browsers or Node with native ESM. The footgun is omitting .js extensions in imports, which it requires in ESM output even for .ts source files.

WHY IT EXISTS: JavaScript historically had no native module system, so Node.js adopted CommonJS while browsers relied on script tags. ES modules standardized import and export syntax, but TypeScript sits in the middle: it lets developers write modern ESM source code while emitting JavaScript that matches their target environment. The problem is that TypeScript's module output is configurable independently of the source syntax, creating a gap between what you write and what actually runs.

THE MENTAL MODEL: Think of TypeScript as a translator, not an enforcer. When you write import { foo } from './foo.js', TypeScript checks types at compile time but strips types and rewrites module syntax based on your tsconfig settings. The .ts file is the contract you read and write; the .js file and its module format are what execute. Your job is to keep the translation honest so the runtime resolver agrees with the compiler. This means the path strings in your imports are really meant for the JavaScript runtime, not for TypeScript itself.

HOW IT WORKS: You set module to ESNext or NodeNext in tsconfig.json to tell TypeScript you want ESM output. If moduleResolution is NodeNext, TypeScript enforces Node.js ESM rules: you must use import and export, you must include file extensions on relative paths, and top-level await is allowed. The compiler does not bundle; it transpiles each .ts file to a .js file with preserved import and export statements. Type stripping is separate from module resolution, so import paths must be valid for the emitted code, not just the TypeScript source. Additionally, package.json type module or individual .mjs extensions tell Node.js to treat the output as ESM, which TypeScript does not control.

WHEN TO USE IT: Use ES modules in TypeScript when you target modern browsers that support script type module, when you publish a library that consumers will tree-shake, or when you run Node.js with type module in package.json. It is also the right choice when you want static analysis, top-level await, or when downstream tooling expects standard ESM static imports rather than dynamic require.

WHEN NOT TO USE IT: Do not use it if you must support legacy Node.js versions before ESM stabilization, or if your codebase depends on CommonJS-specific behaviors like __dirname, require.extensions, or conditional require calls inside functions. If your build pipeline relies on ts-node without ESM loaders, or if you are writing a simple script where CommonJS works without configuration, forcing ESM adds friction without benefit.

ONE CANONICAL EXAMPLE: Imagine a file math.ts that exports a function add which takes two numbers and returns a number. In app.ts you write import { add } from './math.js'. Note the .js extension even though the source file is math.ts. Your tsconfig sets module to ESNext and moduleResolution to bundler. TypeScript compiles app.ts to app.js, preserving the import from './math.js'. At runtime in the browser or Node.js ESM, the resolver finds math.js and loads it as a module. If you had written ./math or ./math.ts, compilation might succeed but runtime resolution would fail because the emitted JavaScript must match the actual file on disk. This is the most common source of confusion when teams first enable ESM output.

Get five bites like this every day.

Tezvyn delivers a daily feed of 60-second tech bites with quizzes to lock in what you learn.