Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Module Resolution Strategies in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

ES Modules in TypeScript
TypeScript
12m
Sep 29, 2026

ES Modules in TypeScript

ES modules are more than import syntax. Learn how module boundaries, type-only imports, compiler resolution, and package exports work together in a production TypeScript codebase.

#TypeScript#ES Modules+5
Setting Up Your TypeScript Development Environment
TypeScript
8m
Nov 13, 2025

Setting Up Your TypeScript Development Environment

Getting started with TypeScript begins with proper setup. This comprehensive guide walks you through installing TypeScript, understanding tsconfig.json, and configuring your IDE for maximum productivity. Whether you're starting a new project or migrating an existing one, you'll learn the essential setup steps and best practices for a smooth TypeScript development experience.

#TypeScript#Setup+6
Using @types Packages in TypeScript
TypeScript
11m
Oct 20, 2026

Using @types Packages in TypeScript

@types packages are useful compatibility contracts for JavaScript libraries—provided their versions, visibility, and runtime behaviour stay aligned.

#TypeScript#@types+4
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Resolution Is a Runtime Contract
  • Choose the Mode That Matches Execution
  • Treat Aliases as Multi-Tool Configuration
  • Respect Package Export Maps
  • Debug Resolution Systematically
  • Separate Source Paths from Published Paths
  • Verify the Consumer Environments
  • Key Takeaways
TypeScript

Module Resolution Strategies in TypeScript

October 6, 2026•6 min read
Joshua R. Lehman
Joshua R. Lehman
Author
Centred technical diagram showing a module specifier resolved through compiler, bundler, and runtime layers
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:

  1. Confirm the exact import specifier and the intended runtime.
  2. Use tsc --traceResolution to see TypeScript's candidate paths and selected file.
  3. Inspect the package's exports, type, and installed declaration files.
  4. Run the real test or production-like build; editor resolution alone is insufficient.
  5. 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 NodeNext or 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 --traceResolution to 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.