Build-time vs Runtime modules
Let’s dive into how module systems behave at build-time vs runtime, and the distinctions between CommonJS (CJS), ECMAScript Modules (ESM), and tools like TypeScript and webpack. I’ll go step by step and also address when module resolution can fail.
1. CommonJS (CJS) — runtime-driven
- Syntax:
const x = require("foo")
- Resolution model:
- Node.js resolves modules at runtime.
- Each
require()
is just a function call; it can be conditional, computed, etc. - Example:
if (condition) { const lib = require("./lib"); }
Node won’t attempt to resolve./lib
until that line executes.
- Build-time behavior (pure JS):
- None. There’s no static analysis of imports.
- Typos, missing files, or circular dependencies won’t show up until the code runs.
- When resolution fails:
- Always runtime. If the file/module doesn’t exist, Node will throw
MODULE_NOT_FOUND
only when execution reaches thatrequire()
.
- Always runtime. If the file/module doesn’t exist, Node will throw
2. ECMAScript Modules (ESM) — static & runtime
- Syntax:
import x from "foo"
- Resolution model:
- ESM enforces static imports at the top level (except for
import()
which is dynamic). - Node (and browsers) must resolve all imports before execution of the module graph begins.
- This gives strong guarantees for tree shaking and faster startup.
- ESM enforces static imports at the top level (except for
- Build-time behavior (pure JS in Node):
- Node validates the existence of each imported file before running the entry module.
- If any
import
target is missing or has an invalid specifier, Node aborts before evaluating anything.
- When resolution fails:
- Static imports → fail at load-time, before executing code.
- Dynamic imports (
await import("foo")
) → fail at runtime, when that line executes.
3. TypeScript compilation
- What TypeScript checks:
- With
"module": "commonjs"
or"module": "esnext"
, TS just emits different JS code (CJSrequire
vs ESMimport
). - TS does a syntactic check: it ensures imported identifiers exist in the type system.
- If you import
./foo
butfoo.ts
doesn’t exist, TS errors at compile-time (unless"skipLibCheck"
or"noEmitOnError": false
allows emitting anyway).
- With
- Key limitation:
- TypeScript doesn’t guarantee runtime resolution. For example:
const name = "./" + something; import(name); // TS won’t error, but Node may fail at runtime
- TypeScript cannot analyze dynamic paths.
- TypeScript doesn’t guarantee runtime resolution. For example:
- When resolution fails:
- Static imports → fail at TypeScript build-time (if file missing).
- Dynamic imports or computed requires → TS compiles fine, failure only at runtime.
4. Webpack bundling
- Build-time behavior:
- Webpack tries to resolve every import/require statically when building the dependency graph.
- For ESM imports, this is straightforward.
- For CommonJS requires, webpack does limited static analysis:
require("./foo")
→ resolved at build-time.require("./" + variable)
→ not statically analyzable, webpack falls back to context modules (bundling more than you might expect).require(dynamicVar)
→ webpack may emit a runtime error or include everything from a directory, depending on config.
- Tree shaking:
- Only possible with ESM because of its static nature.
- CommonJS is opaque: webpack cannot safely remove unused exports.
- When resolution fails:
- Static ESM imports → fail at webpack build-time if module missing.
- Static CommonJS requires → also fail at webpack build-time.
- Dynamic requires/imports:
- If webpack can’t resolve (e.g.,
require(someUnknown)
), it either:- Errors during build (
Critical dependency: the request of a dependency is an expression
). - Or creates a runtime resolver that may fail at runtime.
- Errors during build (
- If webpack can’t resolve (e.g.,
5. Summary — when resolution fails
Case | CJS (Node) | ESM (Node) | TypeScript | Webpack |
---|---|---|---|---|
Static import/require | Runtime (when hit) | Load-time (before execution) | Build-time (TS error) | Build-time (webpack error) |
Dynamic import/require | Runtime only | Runtime only | Runtime only | Build-time if analyzable, otherwise runtime |
Missing identifier in module | Runtime (undefined) | Runtime (ReferenceError) | Build-time (type error) | Build-time (webpack error) |
The key insight:
- CommonJS defers almost everything to runtime.
- ESM shifts validation earlier (load/build-time).
- TypeScript catches missing static imports at compile-time, but not runtime dynamics.
- Webpack tries to resolve as much as possible at build-time; dynamic expressions are the gray zone.