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 that require().

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.
  • 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 (CJS require vs ESM import).
    • TS does a syntactic check: it ensures imported identifiers exist in the type system.
    • If you import ./foo but foo.ts doesn’t exist, TS errors at compile-time (unless "skipLibCheck" or "noEmitOnError": false allows emitting anyway).
  • 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.
  • 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.

5. Summary — when resolution fails

CaseCJS (Node)ESM (Node)TypeScriptWebpack
Static import/requireRuntime (when hit)Load-time (before execution)Build-time (TS error)Build-time (webpack error)
Dynamic import/requireRuntime onlyRuntime onlyRuntime onlyBuild-time if analyzable, otherwise runtime
Missing identifier in moduleRuntime (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.

About Author

Mathias Bothe To my job profile

I am Mathias from Heidelberg, Germany. I am a passionate IT freelancer with 15+ years experience in programming, especially in developing web based applications for companies that range from small startups to the big players out there. I create Bosycom and initiated several software projects.