Module Resolution Strategies in TypeScript


Module resolution answers a deceptively simple question: when code says import { parse } from "@/lib/parse", which file—and which exported condition—does that mean? TypeScript answers for type checking. Your test runner, bundler, Node.js process, and deployed application answer for execution. Production failures occur when those answers differ.
Resolution Is a Runtime Contract
The compiler should model the environment that runs the generated JavaScript. A Node ESM library has different rules from a Next.js application compiled by a bundler. Avoid selecting a mode because it silences an error; select it because it describes the runtime contract.
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true,
"strict": true
}
}That pairing makes TypeScript follow modern Node package conventions. In a framework project, preserve the framework-generated configuration and its documented resolver behaviour instead of copying library settings into it.
Choose the Mode That Matches Execution
NodeNext is appropriate when Node will execute ESM-aware output. bundler is appropriate when a bundler resolves bare specifiers, aliases, and extension handling before code reaches a browser or server runtime. The distinction is not cosmetic: it changes how TypeScript interprets package metadata and relative imports.
Do Not Type Check a Different Application
If Node expects an explicit emitted-file extension but TypeScript accepts a source-only path, configuration is hiding a future runtime failure. Make the source graph honest rather than relying on a compiler loophole.
Treat a resolver upgrade as a compatibility change. Re-run the fixtures after updating TypeScript, Node, a bundler, or a test runner: each can change which package condition wins.
Treat Aliases as Multi-Tool Configuration
An alias belongs to every tool that resolves the import. This common setup is only the TypeScript portion:
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./*"] }
}
}Next.js may understand that convention, while an isolated Node script, a test runner, or a build tool may not. Configure the corresponding resolver or avoid the alias at that boundary. A passing editor and failing test are evidence of a split resolver, not a mysterious cache problem.
Respect Package Export Maps
Modern packages can expose conditional entry points through package.json:
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./testing": {
"types": "./dist/testing.d.ts",
"import": "./dist/testing.js"
}
}
}Import supported paths, not files discovered inside dist/. Deep imports make consumers depend on a package's private build layout and may bypass browser, server, type, or development conditions. Export maps are a boundary that makes compatibility deliberate.
Debug Resolution Systematically
When a specifier fails, isolate the layer before changing settings:
- Confirm the exact import specifier and the intended runtime.
- Use
tsc --traceResolutionto see TypeScript's candidate paths and selected file. - Inspect the package's
exports,type, and installed declaration files. - Run the real test or production-like build; editor resolution alone is insufficient.
- Fix the disagreement at its source, then add a regression test for the import boundary.
Resolution bugs reward small experiments. Replace an alias with one relative import, inspect one package map, or run one trace before changing five compiler flags. The trace is evidence; a configuration cargo-cult is not.
Separate Source Paths from Published Paths
A source-tree alias is convenient, but it is not a public API. Libraries in
particular need to distinguish how maintainers import files in src/ from what
consumers can import after publishing. The emitted JavaScript, declaration
files, export map, and package metadata must describe the latter without
leaking build-only paths.
For example, a library can use relative imports internally while publishing a small explicit surface:
{
"name": "@acme/slugger",
"type": "module",
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./testing": {
"types": "./dist/testing.d.ts",
"import": "./dist/testing.js"
}
},
"files": ["dist"]
}That map is a promise. If a consumer imports @acme/slugger/testing, tests
should prove the JavaScript and declaration target both exist in the packed
artifact. If the package does not intend to support a path, omit it. A clear
failure is preferable to an accidental deep import that works until a build
reorganization.
Type-only imports deserve the same care. With verbatimModuleSyntax, write
import type { Slug } from "./types.js" when the symbol is type-only. This
makes emitted code honest and catches a surprising class of errors where a
compiler setting silently erases an import that the runtime actually needed.
Verify the Consumer Environments
Resolution configuration is only credible when checked from the environments that consume it. A library should type-check its sources, build its distribution files, pack the package, and run a small fixture that imports the tarball using the supported entry points. An application should run its framework build and test the server, edge, and client boundaries that resolve code differently.
Keep a small resolution fixture for every unusual boundary: an ESM-only dependency, a subpath export, an alias used by tests, or a CommonJS interop case. These tests do not need elaborate assertions. Successful import plus one behavioural call is often enough to reveal that the resolver chose an invalid file or condition.
When deployment differs from local development, make the difference explicit.
A serverless runtime may externalize packages that a local bundler inlines. A
monorepo may follow workspace links locally but consume packed artifacts in CI.
Those are deployment facts, not TypeScript defects. Model them in a fixture or
build step instead of compensating with increasingly permissive paths rules.
A Useful Review Question
For every non-relative import, ask: who resolves this in production, which condition does it choose, and is that exact target part of the supported contract? The answer should be visible in configuration or a package map.
A resolution checklist for pull requests
When an import changes, review the emitted path as well as the source path. Check that the package map exposes it, that its types and JavaScript point to matching files, and that every intended runtime understands the selected condition. If an alias crosses a package or process boundary, add the smallest fixture that executes that crossing. This practice is modest, but it prevents the expensive class of defect where local type checking is green while a production worker cannot load the module.
Prefer explicitness over clever compatibility flags. A short relative ESM path with its required extension can communicate more than a resolver rule that only one tool understands. Configuration is part of the program; keep it small enough that a new maintainer can explain how an import reaches production.
Key Takeaways
- Module resolution is a contract across TypeScript and the environment that executes code.
- Choose
NodeNextor bundler-aware resolution based on the actual runtime. - Configure path aliases everywhere they are resolved, or keep them out of that boundary.
- Import package entry points, not private build files.
- Use
--traceResolutionto diagnose the compiler's actual decision. - Test resolution from packed libraries and production-like application builds.
Next, we will use this resolution knowledge to work confidently with declaration files and JavaScript libraries that need explicit TypeScript contracts.