TypeScript — Module Resolution
Module resolution is how TypeScript determines which file an import statement refers to. Understanding resolution strategies is critical for configuring projects that work correctly across Node.js, bundlers, and different module systems. The wrong strategy can cause cryptic errors like "Cannot find module" or "File is not a module."
| 1 | // CommonJS — Node.js original module system |
| 2 | // Uses require() and module.exports |
| 3 | // Synchronous, runtime resolution |
| 4 | |
| 5 | // math.ts (compiled to CommonJS) |
| 6 | export function add(a: number, b: number): number { |
| 7 | return a + b; |
| 8 | } |
| 9 | |
| 10 | export const PI = 3.14159; |
| 11 | |
| 12 | // Compiled output: |
| 13 | // "use strict"; |
| 14 | // Object.defineProperty(exports, "__esModule", { value: true }); |
| 15 | // exports.PI = void 0; |
| 16 | // function add(a, b) { return a + b; } |
| 17 | // exports.add = add; |
| 18 | // exports.PI = 3.14159; |
| 19 | |
| 20 | // ESM — ECMAScript standard |
| 21 | // Uses import/export, static analysis |
| 22 | // Asynchronous, supports top-level await |
| 23 | |
| 24 | // math.ts (compiled to ESM) |
| 25 | export function subtract(a: number, b: number): number { |
| 26 | return a - b; |
| 27 | } |
| 28 | |
| 29 | export const TAU = 2 * Math.PI; |
| 30 | |
| 31 | // Compiled output: |
| 32 | // export function subtract(a, b) { return a - b; } |
| 33 | // export const TAU = 2 * Math.PI; |
| 34 | |
| 35 | // Dynamic import — works in both systems |
| 36 | async function loadModule() { |
| 37 | const { add } = await import("./math"); |
| 38 | console.log(add(1, 2)); |
| 39 | } |
| 40 | |
| 41 | // CommonJS require vs ESM import |
| 42 | const lodash = require("lodash"); // CommonJS |
| 43 | import _ from "lodash-es"; // ESM |
| 44 | import type { lodash } from "lodash"; // TypeScript type-only |
info
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "module": "Node16", |
| 4 | "moduleResolution": "Node16", |
| 5 | "target": "ES2022", |
| 6 | "outDir": "./dist", |
| 7 | "rootDir": "./src" |
| 8 | } |
| 9 | } |
| 1 | // "moduleResolution": "node" — Classic Node.js resolution |
| 2 | // - Looks in node_modules |
| 3 | // - Follows package.json "main" field |
| 4 | // - Supports index.ts in directories |
| 5 | // - Does NOT respect package.json "exports" |
| 6 | |
| 7 | // "moduleResolution": "node16" — Modern Node.js resolution |
| 8 | // - Respects package.json "exports" field |
| 9 | // - Supports dual CJS/ESM packages |
| 10 | // - Requires explicit .js extensions in imports |
| 11 | // - Enforces ESM/CJS interop rules |
| 12 | |
| 13 | // "moduleResolution": "bundler" — For bundled environments |
| 14 | // - Most lenient — lets the bundler resolve |
| 15 | // - Supports index.ts, extensionless imports |
| 16 | // - Supports package.json "exports" |
| 17 | // - Best for Vite, webpack, esbuild, Rollup |
| 18 | |
| 19 | // "moduleResolution": "nodenext" — Same as node16 (alias) |
| 20 | |
| 21 | // Comparison: |
| 22 | // Strategy | extensions | exports field | barrel re-exports |
| 23 | // node | optional | no | yes |
| 24 | // node16/nodenext| required | yes | no |
| 25 | // bundler | optional | yes | yes |
| 26 | |
| 27 | // Import patterns per strategy: |
| 28 | // node16 — requires .js extension |
| 29 | import { add } from "./math.js"; |
| 30 | |
| 31 | // bundler — extension optional |
| 32 | import { add } from "./math"; |
| 33 | |
| 34 | // node — extension optional, no exports support |
| 35 | import { add } from "./math"; |
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "baseUrl": ".", |
| 4 | "paths": { |
| 5 | "@/*": ["./src/*"], |
| 6 | "@components/*": ["./src/components/*"], |
| 7 | "@lib/*": ["./src/lib/*"], |
| 8 | "@types/*": ["./src/types/*"] |
| 9 | } |
| 10 | } |
| 11 | } |
| 1 | // Without path mapping — relative hell |
| 2 | import { Button } from "../../../components/Button"; |
| 3 | import { formatDate } from "../../../lib/utils"; |
| 4 | import { User } from "../../../types/models"; |
| 5 | |
| 6 | // With path mapping — clean imports |
| 7 | import { Button } from "@/components/Button"; |
| 8 | import { formatDate } from "@/lib/utils"; |
| 9 | import { User } from "@/types/models"; |
| 10 | |
| 11 | // Using aliases with barrel exports |
| 12 | // src/components/index.ts |
| 13 | export { Button } from "./Button"; |
| 14 | export { Card } from "./Card"; |
| 15 | export { Modal } from "./Modal"; |
| 16 | |
| 17 | // Clean import via barrel |
| 18 | import { Button, Card, Modal } from "@/components"; |
| 19 | |
| 20 | // Pattern for monorepo path mapping |
| 21 | { |
| 22 | "paths": { |
| 23 | "@app/*": ["./packages/app/src/*"], |
| 24 | "@shared/*": ["./packages/shared/src/*"], |
| 25 | "@ui/*": ["./packages/ui/src/*"] |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | // Vite also needs matching vite.config.ts |
| 30 | // import { defineConfig } from "vite"; |
| 31 | // import tsconfigPaths from "vite-tsconfig-paths"; |
| 32 | // export default defineConfig({ plugins: [tsconfigPaths()] }); |
| 33 | |
| 34 | // Webpack needs matching resolve.alias in webpack.config.js |
| 35 | // resolve: { |
| 36 | // alias: { |
| 37 | // "@": path.resolve(__dirname, "./src"), |
| 38 | // "@components": path.resolve(__dirname, "./src/components"), |
| 39 | // } |
| 40 | // } |
warning
| 1 | // src/components/index.ts — barrel file |
| 2 | // Re-exports all components from a single entry point |
| 3 | |
| 4 | export { Button } from "./Button"; |
| 5 | export { Card } from "./Card"; |
| 6 | export { Modal } from "./Modal"; |
| 7 | export { Input } from "./Input"; |
| 8 | export { Select } from "./Select"; |
| 9 | |
| 10 | // Re-export everything from a module |
| 11 | export * from "./formatters"; |
| 12 | export * from "./validators"; |
| 13 | |
| 14 | // Selective re-export (picking specific items) |
| 15 | export { validateEmail, validatePassword } from "./validators"; |
| 16 | |
| 17 | // Type-only re-export |
| 18 | export type { ButtonProps } from "./Button"; |
| 19 | export type { ModalProps } from "./Modal"; |
| 20 | |
| 21 | // Usage — consumers import from barrel |
| 22 | import { Button, Card } from "@/components"; |
| 23 | import type { ButtonProps } from "@/components"; |
| 24 | |
| 25 | // Barrel with namespace |
| 26 | // src/utils/index.ts |
| 27 | import * as dateUtils from "./dates"; |
| 28 | import * as stringUtils from "./strings"; |
| 29 | import * as arrayUtils from "./arrays"; |
| 30 | |
| 31 | export { dateUtils, stringUtils, arrayUtils }; |
| 32 | |
| 33 | // Or flatten into top-level |
| 34 | export * from "./dates"; |
| 35 | export * from "./strings"; |
| 36 | export * from "./arrays"; |
| 37 | |
| 38 | // Advanced barrel with conditional exports |
| 39 | // package.json |
| 40 | { |
| 41 | "exports": { |
| 42 | ".": { |
| 43 | "import": "./dist/esm/index.js", |
| 44 | "require": "./dist/cjs/index.js", |
| 45 | "types": "./dist/types/index.d.ts" |
| 46 | }, |
| 47 | "./utils": { |
| 48 | "import": "./dist/esm/utils.js", |
| 49 | "require": "./dist/cjs/utils.js", |
| 50 | "types": "./dist/types/utils/index.d.ts" |
| 51 | } |
| 52 | } |
| 53 | } |
warning
| 1 | // Ambient module declarations for untyped packages |
| 2 | // types/legacy-lib.d.ts |
| 3 | declare module "legacy-lib" { |
| 4 | export function doSomething(input: string): number; |
| 5 | export function doAsync(input: string): Promise<number>; |
| 6 | export const VERSION: string; |
| 7 | } |
| 8 | |
| 9 | // Global type declarations |
| 10 | // types/global.d.ts |
| 11 | declare global { |
| 12 | interface Window { |
| 13 | __APP_CONFIG__: { |
| 14 | apiUrl: string; |
| 15 | environment: "development" | "staging" | "production"; |
| 16 | }; |
| 17 | } |
| 18 | |
| 19 | // Augment Node.js globals |
| 20 | namespace NodeJS { |
| 21 | interface ProcessEnv { |
| 22 | NODE_ENV: "development" | "production" | "test"; |
| 23 | DATABASE_URL: string; |
| 24 | API_KEY: string; |
| 25 | } |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | export {}; |
| 30 | |
| 31 | // Module augmentation — extend existing modules |
| 32 | // types/express.d.ts |
| 33 | import { User } from "@/types/models"; |
| 34 | |
| 35 | declare module "express-serve-static-core" { |
| 36 | interface Request { |
| 37 | user?: User; |
| 38 | sessionId?: string; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | // Declaration merging for CSS modules |
| 43 | declare module "*.module.css" { |
| 44 | const classes: Record<string, string>; |
| 45 | export default classes; |
| 46 | } |
| 47 | |
| 48 | declare module "*.module.scss" { |
| 49 | const classes: Record<string, string>; |
| 50 | export default classes; |
| 51 | } |
| 52 | |
| 53 | // Asset declarations |
| 54 | declare module "*.svg" { |
| 55 | const content: React.FC<React.SVGProps<SVGSVGElement>>; |
| 56 | export default content; |
| 57 | } |
| 58 | |
| 59 | declare module "*.png" { |
| 60 | const src: string; |
| 61 | export default src; |
| 62 | } |
| 1 | { |
| 2 | "name": "@myorg/ui-components", |
| 3 | "version": "2.0.0", |
| 4 | "type": "module", |
| 5 | "exports": { |
| 6 | ".": { |
| 7 | "import": { |
| 8 | "types": "./dist/index.d.mts", |
| 9 | "default": "./dist/index.mjs" |
| 10 | }, |
| 11 | "require": { |
| 12 | "types": "./dist/index.d.cts", |
| 13 | "default": "./dist/index.cjs" |
| 14 | } |
| 15 | }, |
| 16 | "./styles": "./dist/styles.css", |
| 17 | "./icons": { |
| 18 | "import": { |
| 19 | "types": "./dist/icons.d.mts", |
| 20 | "default": "./dist/icons.mjs" |
| 21 | } |
| 22 | } |
| 23 | }, |
| 24 | "types": "./dist/index.d.ts", |
| 25 | "main": "./dist/index.cjs", |
| 26 | "module": "./dist/index.mjs" |
| 27 | } |
info
1. Use "moduleResolution": "bundler" for Vite/webpack projects and "node16" for pure Node.js packages.
2. Always add .js extensions in imports when using node16 resolution — even for TypeScript files.
3. Use path aliases (@/*) to avoid deep relative imports like ../../../.
4. Keep barrel files small and focused. For large packages, use subpath exports (@myorg/ui/icons).
5. Use export type for type-only re-exports to prevent runtime side effects.
6. Use module augmentation (declare module) for untyped third-party packages instead of modifying node_modules.
7. Set "type": "module" in package.json for new projects — ESM is the standard going forward.
With moduleResolution: NodeNext, TypeScript mirrors Node ESM rules: extensioned relative imports, package exports, and conditional exports including types.
| 1 | import { util } from "./util.js"; // .js extension even though source is util.ts |
| 2 | import cfg from "#config"; // package imports field |
| 3 | import type { User } from "./user.js"; |
| 1 | { |
| 2 | "type": "module", |
| 3 | "imports": { |
| 4 | "#config": "./dist/config.js" |
| 5 | }, |
| 6 | "exports": { |
| 7 | ".": { |
| 8 | "types": "./dist/index.d.ts", |
| 9 | "import": "./dist/index.js" |
| 10 | } |
| 11 | } |
| 12 | } |
moduleResolution: bundler relaxes extension requirements for apps compiled by Vite/webpack. Still keep runtime Node packages on NodeNext.
| Mode | Best for | Extensions |
|---|---|---|
| bundler | Apps (Vite, Next) | Often optional |
| NodeNext | Libraries / Node ESM | Required for relatives |
| node10 (legacy) | Old CJS | Avoid for new work |
| 1 | import type { User } from "./user.js"; |
| 2 | import { createUser } from "./user.js"; |
| 3 | |
| 4 | export type { User as PublicUser } from "./user.js"; |
| 5 | export { createUser }; |
pro tip
Enabling verbatimModuleSyntax forces type-only imports to use import type, matching emit more closely and catching value/type mixups early.
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "verbatimModuleSyntax": true, |
| 4 | "module": "NodeNext", |
| 5 | "moduleResolution": "NodeNext" |
| 6 | } |
| 7 | } |
| 1 | // Convert CJS require to ESM + types |
| 2 | // const x = require("fs"); |
| 3 | import fs from "node:fs"; |
| 4 | export const read = (p: string) => fs.readFileSync(p, "utf8"); |
note
Extra patterns for production code. Read one, type it from memory, then compare.
| 1 | export function invariant(cond: unknown, msg: string): asserts cond { |
| 2 | if (!cond) throw new Error(msg); |
| 3 | } |
| 4 | |
| 5 | export function unreachable(message = "unreachable"): never { |
| 6 | throw new Error(message); |
| 7 | } |
| 1 | export type Fn<A extends unknown[], R> = (...args: A) => R; |
| 2 | |
| 3 | export function once<A extends unknown[], R>(fn: Fn<A, R>): Fn<A, R> { |
| 4 | let called = false; |
| 5 | let result!: R; |
| 6 | return (...args: A) => { |
| 7 | if (!called) { |
| 8 | result = fn(...args); |
| 9 | called = true; |
| 10 | } |
| 11 | return result; |
| 12 | }; |
| 13 | } |
| 1 | export async function mapPool<T, R>( |
| 2 | items: readonly T[], |
| 3 | limit: number, |
| 4 | worker: (item: T, index: number) => Promise<R>, |
| 5 | ): Promise<R[]> { |
| 6 | const results: R[] = new Array(items.length); |
| 7 | let i = 0; |
| 8 | async function run() { |
| 9 | while (i < items.length) { |
| 10 | const idx = i++; |
| 11 | results[idx] = await worker(items[idx]!, idx); |
| 12 | } |
| 13 | } |
| 14 | await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run())); |
| 15 | return results; |
| 16 | } |
info
| Helper | When to use |
|---|---|
| invariant / asserts | Narrow after runtime checks |
| unreachable / never | Exhaustiveness & dead ends |
| once | Lazy init without races in sync code |
| mapPool | Bounded async concurrency |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.