FullStack Course LogoFullStack Course
Module: TypeScript
TypeScript·171·12 MIN READ

171: ESM, CommonJS, Module Resolution, Package Exports, and Project References

TOPICS COVERED: ESM, CommonJS, Module Resolution, Package Exports, and Project References

Learning outcomes

By the end of this lesson, you should be able to:

  • explain and apply ESM versus CommonJS in a realistic implementation;
  • explain and apply NodeNext in a realistic implementation;
  • explain and apply bundler resolution in a realistic implementation;
  • explain and apply package exports and imports in a realistic implementation;
  • explain and apply path aliases in a realistic implementation.

These are not isolated compiler switches. You should be able to connect each choice to the package metadata, build tool, and runtime that will actually load the code.

Prerequisites and retrieval

This lesson assumes the earlier 01–06 foundation and the preceding lessons in this module. Before reading, retrieve one concrete example from a previous project where this concern appeared. Perhaps an import worked in the editor but failed when Node ran the built output, or perhaps an alias compiled successfully but was not understood by the runtime. Recalling a specific incident gives the terminology somewhere useful to attach.

The goal is not to memorize module terminology. It is to make a defensible decision inside a strict TypeScript codebase where the compiler helps model domain invariants, while still recognizing that static types do not replace runtime validation.

Terminology

  • ESM versus CommonJS: ESM uses import and export with URL-like resolution semantics. CommonJS uses require and module.exports. They can interoperate, but the exact behavior depends on the runtime and toolchain.
  • NodeNext: NodeNext module settings model Node's ESM and CommonJS behavior, including the package type field, file extensions, and conditional exports.
  • Bundler resolution: Bundler mode assumes that a build tool handles extension and package resolution in ways that are not identical to Node's runtime resolution.
  • package exports and imports: The exports field constrains a package's public entry points and can expose different runtime builds or type paths by condition. The imports field provides package-local aliases for internal imports.
  • Path aliases: paths changes how TypeScript resolves an import during type checking and compilation, but it does not automatically teach Node how to resolve that alias at runtime.
  • Project references: References split a large TypeScript codebase into buildable projects with explicit dependencies. With disciplined configuration, they can improve incremental builds and establish declaration boundaries.

Mental model

Treat ESM, CommonJS, Module Resolution, Package Exports, and Project References as one design problem with observable inputs, outputs, invariants, and failure modes. A module import is not resolved by TypeScript alone. The compiler, the package metadata, the runtime loader, and possibly a bundler each participate in deciding what that import means.

That is why many apparent “TypeScript errors” are really mismatches between those participants. A strong implementation makes its assumptions visible, narrows uncertainty at system boundaries, and leaves enough evidence—tests, types, constraints, metrics, or diagrams—to show why the design is safe.

A useful sequence for both production work and interviews is:

text
requirement -> constraints -> model -> implementation -> failure analysis -> verification

Do not jump from a requirement directly to a compiler option or library call. First state what must remain true. Then choose the mechanism that enforces that invariant, and verify the same behavior through the toolchain and runtime that will be used in production.

Deep dive

1. ESM versus CommonJS

The immediate problem is often an import that looks correct in one environment but behaves differently in another. ESM uses import and export and follows URL-like resolution semantics. CommonJS uses require and module.exports. Interoperability exists, but default and named import behavior can vary by runtime, compiler settings, bundler, and the shape of the exported module.

The useful distinction is that these are module systems, not merely two spellings for the same operation. Their loading and interop rules affect how a package is authored, built, tested, and consumed.

Decision rule: Use ESM versus CommonJS deliberately when that choice makes the contract or invariant easier to prove. If a choice only reduces typing while hiding an assumption about the loader, prefer the more explicit design.

2. NodeNext

The common failure is assuming that a successful TypeScript import guarantees that Node can load the emitted file. NodeNext module settings model Node's ESM/CommonJS behavior, including the package type field, file extensions, and conditional exports. They are useful when Node itself is the runtime path you need to model.

There is one subtle detail worth knowing: a source file written in TypeScript may need an explicit .js extension in an ESM import, even though the source file on disk is .ts. The emitted JavaScript is what Node loads, so the specifier needs to describe the runtime file. This is a frequent source of confusion when an editor resolves the source while the runtime resolves the build output.

Decision rule: Use NodeNext deliberately when it makes the contract or invariant easier to prove. If it only reduces typing while hiding an assumption about Node's actual rules, prefer the more explicit design.

3. Bundler resolution

Bundler resolution addresses a different runtime assumption. A bundler may add extensions, follow package conditions, combine modules, and otherwise resolve imports in ways that are not identical to Node's loader. TypeScript's bundler mode models that style of resolution more closely than it models direct execution by Node.

That convenience is valid only when a compatible bundler is the actual runtime path. If the same output will be executed directly by Node, configuration that is accepted by a bundler can conceal an import that Node cannot resolve. The compiler mode should describe the system you will run, not the system that is most convenient in the editor.

Decision rule: Use bundler resolution deliberately when it makes the contract or invariant easier to prove. If it only reduces typing while hiding an assumption about the runtime loader, prefer the more explicit design.

4. package exports and imports

Package metadata is part of the module contract. The exports field constrains which entry points consumers may import, rather than leaving every file in the package implicitly public. It can also expose different runtime builds or declaration paths by condition. Keep those runtime files and declaration paths aligned; otherwise a consumer can type-check against one surface and execute another.

The imports field is for aliases used inside the package, typically with a package-local specifier. It can make internal boundaries explicit without presenting those paths as public consumer APIs. As with every alias, verify the behavior using the resolver that will load the package.

Decision rule: Use package exports and imports deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption about what is public or which condition wins, prefer the more explicit design.

5. Path aliases

Path aliases are a useful authoring feature, but they are not automatically a runtime feature. The paths option affects TypeScript resolution; it does not by itself teach Node how to resolve the aliases in emitted JavaScript. The bundler, runtime, test runner, or deployment configuration needs matching support, or the code should use real package boundaries instead.

This is where people usually get confused: the editor and compiler may report no problem because they understand paths, while the runtime receives the unchanged alias and fails to load it. Check the emitted import and the runtime resolver rather than treating a clean type-check as proof that the application can start.

Decision rule: Use path aliases deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption about runtime configuration, prefer the more explicit design.

6. Project references

As a TypeScript codebase grows, compiling every project together can make feedback slow and blur dependency boundaries. Project references split the codebase into buildable projects with explicit dependencies. They can improve incremental builds, but they require disciplined composite configuration and clear public declaration boundaries.

The reference is not a substitute for architecture. A referenced project still needs a deliberate public API, and consumers need to resolve its declarations and emitted output consistently. Treat the reference graph as part of the build design and inspect it when an incremental build or declaration lookup behaves unexpectedly.

Decision rule: Use project references deliberately when they make the contract or invariant easier to prove. If they only reduce build time while hiding an assumption about project ownership or declarations, prefer the more explicit design.

Worked example

Consider a strict TypeScript codebase where the compiler models domain invariants without pretending that static types replace runtime validation. Start by writing the requirement in one sentence. Then list the input and output contracts, and identify which of the concepts above owns each failure mode.

The important move is separation. Parsing and validation belong at the boundary. Domain rules belong in the domain or service layer. Persistence rules belong in the database or repository. Presentation rules belong in the client. A module boundary should make those ownership decisions easier to see, not hide them behind a convenient import path. Mixing the concerns can make a happy-path demo shorter, but it makes edge cases much harder to reason about.

For example, a package can expose a small domain surface while keeping implementation files private:

ts
// src/domain/index.ts
export type { Task, TaskId } from './task.js';
export { parseTask } from './parseTask.js';

// consumer.ts
import { parseTask, type Task } from './domain/index.js';

The explicit .js specifiers in the source are intentional in a NodeNext-style ESM project: they describe the emitted files that Node will load. The index file also acts as a deliberate public boundary. A consumer does not need to know where Task is implemented, and the package can later restrict direct subpath access with exports.

Walk this example through at least four cases: the normal path; an empty or missing value; a duplicate, retry, or concurrent path where that distinction is relevant; and a dependency failure. For each case, state which layer detects the problem and what the caller observes. Also state whether the failure happens during type checking, package/build resolution, runtime loading, parsing, or domain validation. That level of explanation is expected in a senior code review or technical interview.

Production perspective

Production correctness is broader than “the code works on my machine.” Ask how the design behaves during deploys, retries, partial failure, stale clients, concurrent requests, malformed data, schema changes, and high-cardinality workloads. Module configuration belongs in that discussion: a build that succeeds locally is not enough if the deployed runtime uses a different loader or package condition.

Prefer explicit contracts, bounded resource usage, structured errors, and measurable behavior. Optimize only after you can identify the bottleneck or risk with evidence. For project references, that evidence might be build timing or dependency graph behavior; for module resolution, it might be the emitted specifier and the exact runtime error.

When the topic involves an external dependency, define a timeout and cancellation strategy. When it involves persistence, define transaction and consistency expectations. When it involves user-visible state, define loading, empty, error, stale, and success states. When it involves security, assume the client can be modified and all network input is untrusted.

Guided lab

Create a two-package workspace in ESM using NodeNext. Add package exports and type declarations. Then intentionally break either an extension or an export condition and diagnose the difference between compiler resolution and runtime resolution.

Complete the lab with this discipline:

  1. Write the requirement and two non-requirements.
  2. List the input, output, and error contracts before implementation.
  3. Implement the smallest correct vertical slice.
  4. Add at least one invalid-input test and one edge-case test.
  5. Instrument or inspect the behavior instead of guessing. Compare what TypeScript resolves with what the runtime actually attempts to load.
  6. Refactor one hidden assumption into an explicit type, constraint, function, or configuration setting.
  7. Explain one alternative design and why you did not choose it.
  8. Record a short “what would break at 10× scale?” note.

The point of the deliberate break is not to memorize an error message. It is to identify the boundary that failed: source resolution, emitted output, package metadata, declaration lookup, or runtime loading.

Edge cases and failure modes

  • ESM versus CommonJS: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Include an interop case when one module system consumes the other.
  • NodeNext: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Check package type, emitted extensions, and the conditional export selected by the runtime.
  • Bundler resolution: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Verify both the bundler path and any direct runtime path rather than assuming they resolve identically.
  • package exports and imports: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Check public subpaths, condition ordering, declaration paths, and package-local aliases.
  • Path aliases: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Check the emitted specifier and configure every runtime or test resolver that must understand it.

Common mistakes and debugging

  • Solving the example instead of the requirement: a copied pattern can be syntactically correct but architecturally wrong.
  • Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary” any values.
  • Testing only the happy path and discovering the contracts only after integration.
  • Optimizing before measuring, or selecting a scalable mechanism without a scale requirement.
  • Letting client-side behavior stand in for server-side authorization, validation, or persistence guarantees.
  • Assuming that a successful editor or compiler lookup proves that the emitted code will load in the production runtime.

For debugging, reproduce the smallest failing case. Inspect the actual value, emitted import, package metadata, or execution plan. Trace the boundary where the invariant first becomes false, and fix the owning layer instead of adding a downstream patch. For a module failure, compare the authored specifier, emitted specifier, package.json conditions, declaration path, and runtime loader in that order.

Interview questions

  1. What problem does ESM versus CommonJS solve, and what trade-off or failure mode would make you choose a different approach?
  2. What problem does NodeNext solve, and what trade-off or failure mode would make you choose a different approach?
  3. What problem does Bundler resolution solve, and what trade-off or failure mode would make you choose a different approach?
  4. What problem do package exports and imports solve, and what trade-off or failure mode would make you choose a different approach?
  5. What problem do path aliases solve, and what trade-off or failure mode would make you choose a different approach?

Checkpoint

Without notes, explain ESM, CommonJS, Module Resolution, Package Exports, and Project References to another developer in five minutes. Your explanation must include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson code.

Mastery checklist

  • I can define the core terms precisely.
  • I can choose a design from requirements instead of from habit.
  • I can implement and test the normal path and edge cases.
  • I can explain the runtime, storage, or complexity cost.
  • I can identify which layer owns validation, errors, and recovery.
  • I can compare at least two reasonable alternatives.
  • I can explain how the design changes at larger scale or stricter reliability.

References

Reader page: /typescript/lesson/171/esm-commonjs-module-resolution-package-exports-and-project-references