Capítulo 34 de 36

Chapter 34: Module Theory — How TypeScript Actually Thinks About Modules

Core Idea

TypeScript's module analysis is fundamentally about modeling a host (whatever system — Node.js, a bundler, a browser — actually loads the emitted code) accurately enough to (1) emit a valid module format, (2) guarantee the emitted imports will really resolve at runtime, and (3) assign correct types to imported names — and the whole system only makes sense once you stop thinking in terms of input .ts files and start thinking in terms of output files, because that's what the host actually consumes.

Key Concepts

  • Scripts vs. modules: the historical problem modules solve is that multiple <script>-tag files all share one global scope and can clobber each other's names — a module system gives each file its own scope plus an explicit way to expose things (export) and consume them (import). ESM (native import/export, standardized 2015, in Node.js since v12) and CJS (require/module.exports, Node's original system, no scope isolation built into the language spec) are the two systems TypeScript's docs focus on, though it can emit several others.
  • "Host" is the key vocabulary term: the system outside TypeScript that actually directs module-loading behavior at runtime — a runtime like Node.js running the output JS directly, a bundler consuming inputs/outputs and producing a bundle, or (for a TS-native runtime) the runtime consuming .ts files directly. A second transpiler/formatter that just passes imports/exports through untouched is not a host TypeScript needs to model. TypeScript itself is never a host — it only tries to accurately model whatever the real host will do.
  • Everything is decided relative to output files, not input files: because the host consumes the emitted JS (or, for TS-native runtimes, the .ts files being run directly), TypeScript's whole module-analysis job is: emit the output format the host expects, make sure the imports in that output actually resolve, and assign correct types to what's imported — all three judged against the host's real rules, not against what the source file happens to look like.
  • The module compiler option decides emitted format — and matters even with noEmit, because it also controls how TypeScript detects each file's module kind for type-checking/resolution purposes, independent of whether JS is actually written to disk.
  • Module format detection (Node.js specifically, under node16/node18/nodenext): .mjs/.cjs (and their TS counterparts .mts/.cts) are unambiguous by extension; a plain .js/.ts file's format depends on the nearest ancestor package.json's "type" field ("module" → ESM, anything else/absent → CJS). TypeScript applies this exact algorithm to input files to predict what the output file's real runtime format will be — getting this right is what prevents emitting import/export syntax into a file Node.js will parse as CJS (syntax error) or require/module.exports into a file Node.js will treat as ESM (runtime crash, since CJS globals aren't injected into ESM scope).
  • Input syntax is decoupled from output format: a source file written entirely with import/export syntax can still emit as CommonJS (require/exports) depending on module and the detected format — so looking at a file's source syntax alone doesn't tell you whether it's "really" ESM or CJS at runtime. This ambiguity is exactly why TypeScript 5.0 added verbatimModuleSyntax, which forces each file's written import/export syntax to already match the form it will emit as (ESM syntax only in files that will emit ESM, import x = require(...)/export = only in files that will emit CJS) — recommended for mixed-format Node.js projects, not recommended for CJS projects that might migrate to ESM later.
  • ESM/CJS interop has no single standard — three broad camps exist: ESM-only hosts (browsers), "bundler-like" hosts (permissive rules descending from how Babel's ESM→CJS transpilation historically interacted with hand-written CJS), and Node.js's own specific rules (pre-v20.19 CJS couldn't require ESM synchronously at all, only via dynamic import(); ESM default-importing CJS always binds to exports as a whole). module: node16/node18/nodenext enforces Node's actual version-specific rules; every other module setting combined with esModuleInterop gives bundler-like interop instead.
  • Module specifiers are never rewritten by tsc — whatever string appears in from "..." is emitted verbatim regardless of module; only the surrounding import/export/require syntax gets transformed. This means module specifiers in source must already be written in a form valid for the eventual output/runtime — the reason Node16/nodenext-targeting ESM code has to write relative imports with an explicit .js extension even though the source files are .ts.
  • Module resolution is entirely host-defined — the ECMAScript spec says how import/export link, but says nothing about how a specifier string maps to an actual file; that's each host's own invention. moduleResolution tells TypeScript which host's algorithm to imitate: classic (legacy, RequireJS-era, scheduled for deprecation), node10 (models pre-v12 Node.js, an increasingly bad model of modern Node.js), node16/nodenext (Node.js's actual dual ESM/CJS resolution rules — locked to the matching module setting), and bundler (models the permissive, extension-optional-even-for-package.json-exports-consumers behavior common to modern bundlers and Bun; requires module: esnext).
  • The "remapping" mental model: don't think "./math resolves directly to math.ts" — think "main.ts maps to output main.js, which resolves via the module specifier to output math.js, which maps back to input math.ts for type information." This model explains otherwise-confusing behavior, like a Node16-mode .mts file needing to import a sibling via "./math.mjs" even though no math.mjs file exists next to the .mts sources yet (it will, after compilation, possibly in a different outDir) — the specifier has to be valid for the output pairing, not the input one.
  • Declaration files (.d.ts) are the type half of that output pairing for already-compiled code: tsc --declaration on one .ts input always produces both a .js and a matching .d.ts, so wherever the compiler finds a declaration file, it assumes a corresponding JS file exists with the shape the declaration describes — and it stops looking further once it finds either a source .ts or a .d.ts, for performance. The declaration file's own extension implies the paired JS extension: .d.ts.js, .d.mts.mjs, .d.cts.cjs, and (with allowArbitraryExtensions) .d.*.ts.* for non-JS assets imported as modules. Hand-written/hand-copied declaration files (common on DefinitelyTyped) that don't follow this naming discipline can produce false-positive errors for consumers — tools like @arethetypeswrong/cli exist specifically to catch that mismatch before publishing.
  • Bundlers/TS-native runtimes are a special case of the output-file model: when there genuinely is no separate output file (a bundler transpiling in-memory, or a TS-native runtime like Bun/Deno/tsx running .ts directly), the usual "must write the output extension in imports" restriction becomes unhelpful — noEmit/emitDeclarationOnly plus allowImportingTsExtensions lets source files import each other with real .ts extensions instead. moduleResolution: bundler is the matching resolution mode — Node.js-inspired but without Node's strict ESM extension-search restrictions.
  • Library authors should prefer the strictest applicable resolution mode, not the most permissive: compiling a published library with moduleResolution: bundler (or the legacy node10) can silently produce output that only actually resolves under a bundler's permissive rules — e.g. export * from "./utils" (no extension) compiles cleanly under bundler/esnext but crashes at runtime under real Node.js ESM resolution, which requires the extension. Since a library doesn't control its consumer's host, targeting module: node18 (which implies the stricter node16 resolution) is the safer default — code that satisfies Node's stricter rules tends to also work in bundlers and other runtimes, while the reverse isn't guaranteed. This guidance only applies when tsc itself produces the shipped output; a library that bundles before publishing shifts that responsibility to its bundler instead.

Code Examples

// src/main.mts, compiled with moduleResolution: node16, outDir: dist
import { add } from "./math.mjs"; // .mjs, even though the sibling *source* file is math.mts
  • What it demonstrates: the "remapping" model in action — the specifier has to match the eventual output pairing (dist/main.mjsdist/math.mjs), which TypeScript then maps back to src/math.mts for type information, not a direct input-to-input reference.

Reference Tables

moduleResolutionModelsNotes
classiclegacy RequireJS-era resolutionscheduled for deprecation, avoid
node10 (formerly node)pre-v12 Node.jspoor model of modern Node.js, avoid for new projects
node16 / nodenextreal Node.js dual ESM/CJS resolutionmust pair with matching module setting
bundlermodern bundlers / Bunrequires module: esnext; permissive, extension-optional even with exports

Key Takeaways

  1. Always reason about module resolution/format in terms of output files and the real host, not the literal text of the input source — this resolves most "why does this import look weird" confusion.
  2. module matters for type-checking accuracy even under noEmit — it's not purely an emit-format switch.
  3. Libraries shipped as raw tsc output should target the strictest applicable settings (node18/node16 resolution), since permissive bundler-only output can crash for consumers running under stricter hosts like real Node.js ESM.
  4. A .d.ts file's own extension (.d.ts/.d.mts/.d.cts) is a promise about its paired JS file's extension and format — hand-maintained declaration files that break this pairing cause false-positive errors downstream.

Connects To

  • Module Syntax & the module Compiler Option: the concrete option values this chapter's theory motivates.
  • Module Resolution Reference: the mechanical lookup rules (paths, node_modules, package.json exports) that moduleResolution modes described here actually perform.
  • ESM/CJS Interop (next chapter): a deeper dive on the interoperability rules only summarized here.
  • Choosing Compiler Options (next chapter): practical settings guidance building directly on this theory.