ESM Semantics in TypeScript
ESM Semantics in TypeScript
ESM is statically analyzed: imports are hoisted, bindings are live, and this at module top level is undefined. TypeScript mirrors that with import/export and erases types at emit — but how it emits depends on module:
{
"type": "module",
"compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext" }
}
With nodenext, Node decides the format from type and file extension: .ts beside "type": "module" emits ESM. Relative specifiers must carry .js (import { f } from "./f.js") because Node does not guess extensions — the file on disk is what gets loaded.
Resolution Options and Path Aliases
Resolution Options and Path Aliases
Match resolution to the loader:
{ "moduleResolution": "nodenext" } // Node ESM/CJS, honors "exports"
{ "moduleResolution": "bundler" } // Vite/webpack: extensionless, alias-driven
{ "moduleResolution": "node10" } // legacy node_modules walking only
Bare specifiers resolve through the dependency's exports map under nodenext; a package that only ships main may fail there while working under bundler. Aliases come from paths:
{ "paths": { "@ui/*": ["./src/ui/*"] } }
Vite needs a matching resolve.alias, Jest needs moduleNameMapper, and Node needs an import map or a re-barrel — otherwise types compile while runtime resolution fails.
Pitfalls and Interview Angles
Pitfalls and Interview Angles
Extension-less imports compile under bundler and crash under Node ESM with ERR_MODULE_NOT_FOUND. Keep the strictest resolution the deployment uses.
Dual package hazard. Shipping both ESM and CJS builds can load two module instances — one for import, one for require — breaking instanceof and singletons. Exports maps with import/require conditions plus a shared state module (or ESM-only) avoid it.
CJS globals vanish. __dirname and require do not exist in ESM; use import.meta.url and dynamic import().
Interview: walk a specifier from source through exports conditions to a loaded module, then explain why createRequire is a symptom, not a fix.
Quiz
1. Why does import { f } from "./f" fail in Node ESM?
2. What do the import and require conditions in a package exports map do?
3. Where must path aliases be mirrored for the application to actually run?
Flashcards
Question
moduleResolution: nodenext
Click to reveal answer
Answer
Node rules: explicit .js extensions, honors package exports conditions, format from type field.
Question
Dual package hazard
Click to reveal answer
Answer
Loading a package once as ESM and once as CJS yields two instances and breaks identity checks.
Question
exports map
Click to reveal answer
Answer
Package entry table with import/require/types conditions controlling what each loader receives.
Question
import.meta.url
Click to reveal answer
Answer
ESM replacement for __dirname-style path math; __dirname does not exist in ESM.
Question
Live bindings
Click to reveal answer
Answer
ESM exports reflect the exporter's current value — reassignment upstream is visible to importers.
Revision Notes
Key Takeaways
- 1. Format comes from package type and module setting; resolution comes from moduleResolution
- 2. Aliases live in three places: compiler, bundler, test runner
- 3. Shipping both formats invites duplicate-module bugs
Interview Tips
- • Trace one import end to end and name which tool resolves each hop
- • Explain dual-package hazard with a concrete instanceof failure
Cheat Sheet
type:module + module/moduleResolution nodenext; relative imports need .js; paths must be mirrored in bundler/Jest; exports conditions split import vs require builds.