|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/modules
$cat docs/typescript-—-module-resolution.md
updated Last week·16 min read·published

TypeScript — Module Resolution

TypeScriptIntermediate🎯Free Tools
Introduction

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."

Module Systems: CommonJS vs ESM
module_systems.ts
TypeScript
1// CommonJS — Node.js original module system
2// Uses require() and module.exports
3// Synchronous, runtime resolution
4
5// math.ts (compiled to CommonJS)
6export function add(a: number, b: number): number {
7 return a + b;
8}
9
10export 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)
25export function subtract(a: number, b: number): number {
26 return a - b;
27}
28
29export 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
36async function loadModule() {
37 const { add } = await import("./math");
38 console.log(add(1, 2));
39}
40
41// CommonJS require vs ESM import
42const lodash = require("lodash"); // CommonJS
43import _ from "lodash-es"; // ESM
44import type { lodash } from "lodash"; // TypeScript type-only

info

When "type": "module" is set in package.json, TypeScript treats .ts files as ESM. Without it, files are treated as CommonJS.
Module Resolution Strategies
tsconfig.json
JSON
1{
2 "compilerOptions": {
3 "module": "Node16",
4 "moduleResolution": "Node16",
5 "target": "ES2022",
6 "outDir": "./dist",
7 "rootDir": "./src"
8 }
9}
resolution_strategies.ts
TypeScript
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
29import { add } from "./math.js";
30
31// bundler — extension optional
32import { add } from "./math";
33
34// node — extension optional, no exports support
35import { add } from "./math";
Path Mapping with baseUrl and paths
tsconfig.json
JSON
1{
2 "compilerOptions": {
3 "baseUrl": ".",
4 "paths": {
5 "@/*": ["./src/*"],
6 "@components/*": ["./src/components/*"],
7 "@lib/*": ["./src/lib/*"],
8 "@types/*": ["./src/types/*"]
9 }
10 }
11}
path_mapping.ts
TypeScript
1// Without path mapping — relative hell
2import { Button } from "../../../components/Button";
3import { formatDate } from "../../../lib/utils";
4import { User } from "../../../types/models";
5
6// With path mapping — clean imports
7import { Button } from "@/components/Button";
8import { formatDate } from "@/lib/utils";
9import { User } from "@/types/models";
10
11// Using aliases with barrel exports
12// src/components/index.ts
13export { Button } from "./Button";
14export { Card } from "./Card";
15export { Modal } from "./Modal";
16
17// Clean import via barrel
18import { 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

TypeScript path mapping only affects type checking. Bundlers and runtime loaders need their own configuration (Vite's vite-tsconfig-paths, Webpack aliases) to resolve the same paths.
Barrel Exports
barrel_exports.ts
TypeScript
1// src/components/index.ts — barrel file
2// Re-exports all components from a single entry point
3
4export { Button } from "./Button";
5export { Card } from "./Card";
6export { Modal } from "./Modal";
7export { Input } from "./Input";
8export { Select } from "./Select";
9
10// Re-export everything from a module
11export * from "./formatters";
12export * from "./validators";
13
14// Selective re-export (picking specific items)
15export { validateEmail, validatePassword } from "./validators";
16
17// Type-only re-export
18export type { ButtonProps } from "./Button";
19export type { ModalProps } from "./Modal";
20
21// Usage — consumers import from barrel
22import { Button, Card } from "@/components";
23import type { ButtonProps } from "@/components";
24
25// Barrel with namespace
26// src/utils/index.ts
27import * as dateUtils from "./dates";
28import * as stringUtils from "./strings";
29import * as arrayUtils from "./arrays";
30
31export { dateUtils, stringUtils, arrayUtils };
32
33// Or flatten into top-level
34export * from "./dates";
35export * from "./strings";
36export * 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

Barrel exports can significantly increase bundle size in non-tree-shakeable environments. Each barrel re-export pulls in the entire module. Use export type for type-only re-exports and ensure your bundler supports tree-shaking.
Declaration Files (.d.ts)
declarations.d.ts
TypeScript
1// Ambient module declarations for untyped packages
2// types/legacy-lib.d.ts
3declare 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
11declare 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
29export {};
30
31// Module augmentation — extend existing modules
32// types/express.d.ts
33import { User } from "@/types/models";
34
35declare module "express-serve-static-core" {
36 interface Request {
37 user?: User;
38 sessionId?: string;
39 }
40}
41
42// Declaration merging for CSS modules
43declare module "*.module.css" {
44 const classes: Record<string, string>;
45 export default classes;
46}
47
48declare module "*.module.scss" {
49 const classes: Record<string, string>;
50 export default classes;
51}
52
53// Asset declarations
54declare module "*.svg" {
55 const content: React.FC<React.SVGProps<SVGSVGElement>>;
56 export default content;
57}
58
59declare module "*.png" {
60 const src: string;
61 export default src;
62}
package.json Exports Map
package.json
JSON
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

The exports field with types condition is the modern way to provide type definitions. Place types first in each condition block to ensure TypeScript resolves it before runtime code.
Best Practices

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.

$Blueprint — Engineering Documentation·Section ID: TS-MODRES·Revision: 1.0