cheat sheet

TypeScript Modules

Deep dive into TypeScript's module system — module/moduleResolution pairs, .mts/.cts file extensions, NodeNext vs Bundler resolution, paths, and tsconfig extends.

TypeScript Modules — module, moduleResolution, .mts / .cts

What it is

TypeScript inherits JavaScript's two module systems (CommonJS and ES Modules) and adds its own machinery on top: the module option controls what shape of JavaScript the compiler emits, while moduleResolution controls how the compiler finds imports at type-check time. The pair must be chosen together — a mismatch produces phantom "Cannot find module" errors that disappear only when both options agree. Modern projects pick one of three combinations: NodeNext (native Node ESM), Bundler (Vite/esbuild/Bun), or Preserve (let the bundler decide).

Why module and moduleResolution exist

TypeScript is a source-to-source compiler: it reads .ts files and writes .js files. It does not run the code. That means the compiler must answer two separate questions before it can do its job:

  1. Emit shape — what JS module format should the output use? CommonJS require()? Native ESM import? Or should it preserve whatever the source wrote?
  2. Resolution — when source code says import { x } from './lib', which file on disk is that referring to? ./lib.ts? ./lib/index.ts? Should the import keep its .ts extension or be rewritten to .js?

module answers the first; moduleResolution answers the second. Pre-TypeScript 4.7, the compiler made conservative defaults and the two settings rarely needed individual tuning. Since Node added native ESM, they must be set explicitly and in agreement.

bash
tsc --showConfig

Output:

text
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022"
  }
}

The module option

The module flag picks the emit shape. Each value implies a default moduleResolution, but you should set both explicitly for clarity.

json
{
  "compilerOptions": {
    "module": "NodeNext"
  }
}

Output: (none — exits 0 on success)

ValueEmitsUse when
CommonJSrequire() / module.exportsLegacy Node, no ESM consumers
ES2015 / ES2020 / ESNextimport / export (no interop)Bundlers that handle Node module field maps themselves
Node16ESM or CJS based on file extension and package.json "type"Node 16+ projects pinned to that semantics
NodeNextSame as Node16 but tracks the latest Node ESM rulesModern Node 18+ libraries and applications
PreserveKeeps import/export exactly as authored (TS 5.4+)Bundlers that want raw ESM out

The NodeNext and Node16 modes are the only ones that care about file extensions and package.json. Under those modes, foo.ts compiles to CJS if it's in a "type": "commonjs" package, and to ESM if it's in a "type": "module" package. Override per-file with the .mts or .cts extensions.

typescript
// In a "type": "module" package
// src/app.ts → compiled as ESM
// src/legacy.cts → compiled as CJS (extension overrides)
// src/loader.mts → compiled as ESM (extension overrides — same result here)

The moduleResolution option

moduleResolution decides how an import specifier becomes a file path. Each value pairs naturally with one module setting.

json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

Output: (none — exits 0 on success)

ValueBehaviour
ClassicLegacy TS 1.x algorithm. Do not use.
NodeOriginal Node-style resolution: ./lib./lib.ts, ./lib/index.ts, walk up node_modules. CJS-flavoured.
Node10New name for Node (TS 5.0+). Same algorithm.
Node16 / NodeNextNative ESM resolution: requires explicit .js extension on relative imports, honours package.json "exports" field.
BundlerVite/esbuild/Webpack-style: extensionless relative imports OK, honours "exports", allows paths.

The single biggest source of Cannot find module errors is mixing modes. If module is NodeNext but moduleResolution is Node, TypeScript emits ESM but tries to resolve like CJS. Conversely, module: ESNext with moduleResolution: NodeNext forces you to write .js extensions even in a Vite project.

NodeNext vs Bundler — picking the right pair

Two modern presets cover most projects. Pick based on who runs the compiled JavaScript.

NodeNext (compiled output runs in Node directly)

Use when you ship a library or a Node application where node dist/index.js is the runtime. You must write .js extensions in your source even though the source files are .ts.

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true
  },
  "include": ["src"]
}
typescript
// src/server.ts
import { createServer } from './http.js';   // .js, not .ts!
import { config } from './config.js';
import express from 'express';              // bare specifier — no extension

Output: (none — exits 0 on success)

The .js rule trips up newcomers. The reason: TypeScript does not rewrite import specifiers. After compilation, import './http.js' is literally what Node sees, and Node requires the extension. The .ts extension is forbidden at runtime; .js is what the file becomes.

Bundler (compiled output runs through a bundler)

Use with Vite, esbuild, Webpack, Bun, or any tool that does its own module resolution. Extensions are optional and paths aliases work without runtime plugins.

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }
  },
  "include": ["src"]
}
typescript
// src/main.ts
import { App } from './App';            // extensionless OK
import { router } from '@/router';      // alias OK
import { fetcher } from './api.ts';     // .ts extension OK (allowImportingTsExtensions)

Output: (none — exits 0 on success)

Bundler mode lights up allowImportingTsExtensions, which lets you write import './foo.ts' in source. This is useful for Deno/Bun compatibility where .ts is the canonical extension. The bundler strips or rewrites the extension during build.

The .mts and .cts file extensions

TypeScript mirrors Node's .mjs / .cjs convention with .mts and .cts. These extensions override the package-level "type" for a single file, which is invaluable when you need a one-off CJS file in an ESM package or vice versa.

typescript
// pkg/package.json — { "type": "module" }
// All .ts files compile to ESM by default.

// pkg/src/loader.mts  → emits loader.mjs (ESM, regardless of "type")
// pkg/src/legacy.cts  → emits legacy.cjs (CJS, regardless of "type")
// pkg/src/main.ts     → emits main.js   (ESM — inherits "type": "module")

Output: (none — exits 0 on success)

Source extEmitsForces
.ts.jsWhatever package.json "type" says
.mts.mjsESM
.cts.cjsCJS
.tsx.js (with JSX transform)Same as .ts for module shape

Use .cts for a CommonJS bootstrap file in an ESM package:

typescript
// scripts/seed.cts — CJS one-off script in an ESM project
const { readFileSync } = require('node:fs');
const data = JSON.parse(readFileSync('./seed.json', 'utf8'));
console.log(`loaded ${data.users.length} users`);
bash
tsc && node scripts/seed.cjs

Output:

text
loaded 42 users

Use .mts for an ESM utility in a legacy CJS package:

typescript
// scripts/check-url.mts — ESM utility in a CJS package
const res = await fetch('https://example.com');
console.log(res.status);
bash
tsc && node scripts/check-url.mjs

Output:

text
200

The .js extension requirement under NodeNext

This is the single most-asked TypeScript question of the last few years. Under module: NodeNext, every relative import must include a .js extension — even though the source file is .ts.

typescript
// Right
import { add } from './math.js';
import { User } from './models/user.js';

// Wrong — TS error TS2835
import { add } from './math';
import { User } from './models/user';

The reason is mechanical: TypeScript compiles ./math.ts to ./math.js. After compilation, the file on disk is ./math.js, and Node's ESM resolver requires the extension. Since TypeScript will not rewrite your import specifier, you must write the final-form .js extension from the start.

For directories that have an index.ts, you must spell out ./folder/index.js:

typescript
import { thing } from './folder/index.js';   // not './folder' under NodeNext

moduleResolution: Bundler removes this requirement because bundlers do rewrite. Node10 resolution doesn't enforce it either — but it's stuck on the legacy CommonJS-flavoured algorithm and ignores "exports".

When migrating a project from Node to NodeNext, run tsc once and fix every "Cannot find module" by appending .js to the specifier. ESLint's import/extensions rule can autofix the lot.

The paths option and bundler agreement

The paths option lets you alias import specifiers (@/components/Button) to file paths (src/components/Button.ts). Critical detail: TypeScript only uses paths for type-checking, not for emit. The bundler must agree, or your built JS will have unresolved @/... imports.

json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@ui/*": ["src/components/ui/*"],
      "@lib/*": ["src/lib/*"]
    }
  }
}

In Vite, mirror the same mapping in vite.config.ts:

typescript
// vite.config.ts
import { defineConfig } from 'vite';
import { fileURLToPath, URL } from 'node:url';

export default defineConfig({
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
      '@ui': fileURLToPath(new URL('./src/components/ui', import.meta.url)),
      '@lib': fileURLToPath(new URL('./src/lib', import.meta.url)),
    },
  },
});

In esbuild, use the tsconfig-paths plugin or copy the same map into the alias option. In Node directly (no bundler), paths does not work at runtime — you need tsconfig-paths/register or a custom loader.

bash
node --import tsconfig-paths/register dist/index.js

Output: (none — exits 0 on success)

paths is only honoured by moduleResolution: Bundler, Node, Node10, or Node16/NodeNext when the alias still resolves to a path that Node can find at runtime. The compiler will silently emit your aliased imports — it's up to runtime tooling to resolve them.

Composite projects and extends

The extends field inherits compiler options from another tsconfig. Combined with composite: true and references, it lets you split a monorepo into typed sub-projects that build incrementally.

json
{
  "extends": "@tsconfig/node20/tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

You can extend from an npm package (@tsconfig/node20), a relative path (./tsconfig.base.json), or chain multiple bases:

json
{
  "extends": ["./tsconfig.base.json", "./tsconfig.strict.json"],
  "compilerOptions": {
    "outDir": "dist"
  }
}

Multi-config extension (TS 5.0+) merges right-to-left, so the last file in the array wins on conflicts.

For monorepos, point references at sub-package tsconfigs. Each referenced project must set composite: true:

json
{
  "references": [
    { "path": "./packages/ui" },
    { "path": "./packages/shared" }
  ]
}
bash
tsc --build

Output:

text
[12:40:11] Starting compilation in watch mode...
[12:40:11] Building project 'packages/shared'
[12:40:11] Building project 'packages/ui'
[12:40:11] Building project 'apps/web'
[12:40:13] Build complete. Watching for file changes.

verbatimModuleSyntax and isolatedModules

Two flags interact closely with module to make the emit predictable.

isolatedModules: true — guarantees that every file can be transpiled in isolation, without cross-file type information. Required by Babel, esbuild, swc, and most bundlers that transpile per-file in parallel. Forces explicit export type for re-exported types and forbids const enum.

verbatimModuleSyntax: true (TS 5.0+, supersedes importsNotUsedAsValues and preserveValueImports) — makes the rule trivially clear: imports without type are kept verbatim; imports with type are erased. No magic elision.

json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "verbatimModuleSyntax": true,
    "isolatedModules": true
  }
}
typescript
// With verbatimModuleSyntax: true
import { Foo } from './foo.js';        // kept — Foo had better be a value
import type { Bar } from './foo.js';   // erased
import { type Baz, qux } from './foo.js'; // Baz erased, qux kept

verbatimModuleSyntax is incompatible with module: CommonJS when you write import/export syntax. The flag assumes ESM emit. Use it with NodeNext, ESNext, or Preserve.

Common errors and what they really mean

The error messages around modules are notoriously cryptic. Here's the decoder ring.

bash
tsc src/app.ts

Output:

text
src/app.ts:1:25 - error TS2307: Cannot find module './lib' or its corresponding type declarations.

Means: under NodeNext resolution, you wrote ./lib but the runtime needs ./lib.js. Add the extension.

text
src/app.ts:3:1 - error TS1259: Module 'fs' can only be default-imported using the 'esModuleInterop' flag

Means: enable esModuleInterop: true and import fs from 'node:fs' will work. Without the flag, use import * as fs from 'node:fs'.

text
src/app.ts:5:8 - error TS1192: Module '"./helper"' has no default export.

Means: you wrote import helper from './helper.js' but helper.ts uses named exports. Either change to import { helper } from './helper.js' or add a default export. Often paired with a CJS interop confusion — turn on allowSyntheticDefaultImports to get the IDE to stop complaining (but the runtime still must agree).

text
error TS5095: Option 'bundler' can only be used when 'module' is set to 'es2015' or later.

Means: you set moduleResolution: Bundler but left module: CommonJS. Change module to ESNext or similar.

Common pitfalls

  1. Mismatched module and moduleResolution — symptoms are phantom missing modules. Always set both explicitly to the same family (NodeNext/NodeNext, ESNext/Bundler).
  2. Forgetting .js on relative imports under NodeNext — TS does not rewrite specifiers. The source file is .ts but the import must read .js.
  3. paths works in editor but breaks at runtime — the editor uses TypeScript's paths; Node doesn't. Add a runtime resolver or mirror the alias in the bundler.
  4. Using .mts/.cts to "fix" a missing-default error — extension only changes module shape, not the contents. The fix is in the import / export form.
  5. Adding allowImportingTsExtensions: true to a NodeNext config — only allowed with noEmit: true (or Bundler resolution). Otherwise TS refuses to start.
  6. module: NodeNext with an ancient target: ES5 — produces invalid output (CJS files trying to use ES5 syntax in async contexts). Bump target to ES2022 or later.
  7. Symlinks bypass paths — pnpm's symlinked node_modules sometimes confuses paths. Set preserveSymlinks: false (the default) and configure the bundler to match.
  8. extends from a non-installed package@tsconfig/node20 must be in devDependencies. CI fails because the base config isn't present.
  9. isolatedModules complains about const enum — Babel and esbuild can't see across files to inline the values. Convert to a regular enum or a const object pattern.
  10. exports in package.json blocks subpaths — once you add an "exports" map, consumers can no longer reach paths you haven't listed. Add an explicit "./*": "./*" if you want the old open behaviour back.

Real-world recipes

A tsconfig that works for both Node ESM and a bundler

Split into a base and two derived configs. The base sets the strict checks; the derivations pick the emit shape.

json
// tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}
json
// tsconfig.node.json — runs in Node directly
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
json
// tsconfig.bundler.json — for Vite / Bun / esbuild
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["src"]
}

Build each independently:

bash
tsc -p tsconfig.node.json
tsc -p tsconfig.bundler.json --noEmit

Output: (none — exits 0 on success)

Mixing CJS and ESM with .cts / .mts

A library that ships as ESM but needs a CJS shim for legacy consumers can author both file types side by side and let tsc emit each correctly.

typescript
// src/index.mts — the ESM entry
export { compute } from './compute.js';
export type { ComputeOptions } from './compute.js';
typescript
// src/index.cts — the CJS shim
const { compute } = require('./compute.js');
module.exports = { compute };
json
{
  "exports": {
    ".": {
      "types": "./dist/index.d.mts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}
bash
tsc

Output: (none — exits 0 on success)

The dist/ directory now contains both index.mjs and index.cjs. Node picks the right one based on how the consumer imports.

Migrating from module: CommonJS to NodeNext

Step-by-step on a real codebase.

bash
# 1. flip the compiler options
git diff tsconfig.json

Output:

text
-    "module": "CommonJS",
-    "moduleResolution": "Node",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "verbatimModuleSyntax": true,
bash
# 2. add "type": "module" to package.json
node -e 'const p=require("./package.json"); p.type="module"; require("fs").writeFileSync("package.json", JSON.stringify(p, null, 2))'

Output: (none — exits 0 on success)

bash
# 3. let tsc enumerate the broken imports
tsc --noEmit 2>&1 | grep "TS2307" | head

Output:

text
src/app.ts:1:25 - error TS2307: Cannot find module './lib' or its corresponding type declarations.
src/lib.ts:3:30 - error TS2307: Cannot find module './util' or its corresponding type declarations.
src/util.ts:1:1  - error TS2307: Cannot find module './shared' or its corresponding type declarations.
bash
# 4. autofix .js extensions with ESLint
npx eslint --fix --rule 'import/extensions: ["error", "always"]' src

Output:

text
3 problems (3 errors, 0 warnings)
3 errors and 0 warnings potentially fixable with the `--fix` option.
bash
# 5. rerun the compiler
tsc --noEmit && echo "clean"

Output:

text
clean

A monorepo with project references

Two packages and an app. Each sub-package is composite: true; the root config references them.

text
repo/
├── tsconfig.json              ← references both packages
├── package.json
├── packages/
│   ├── ui/
│   │   ├── tsconfig.json      ← composite: true
│   │   └── src/index.ts
│   └── shared/
│       ├── tsconfig.json      ← composite: true
│       └── src/index.ts
└── apps/
    └── web/
        ├── tsconfig.json      ← references ../packages/ui, ../packages/shared
        └── src/main.ts
json
// packages/shared/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
json
// apps/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noEmit": true
  },
  "include": ["src"],
  "references": [
    { "path": "../../packages/ui" },
    { "path": "../../packages/shared" }
  ]
}
bash
tsc --build apps/web

Output:

text
[12:42:01] Starting compilation in watch mode...
[12:42:01] Building project 'packages/shared'
[12:42:01] Building project 'packages/ui'
[12:42:02] Building project 'apps/web'
[12:42:02] Build complete. Watching for file changes.

Allowing import './foo.ts' for Bun and Deno

Deno and Bun let you write import './foo.ts' directly. To get the same in a TS project that compiles to JS via a bundler, set:

json
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "allowImportingTsExtensions": true,
    "noEmit": true
  }
}
typescript
import { run } from './main.ts';   // OK

Output: (none — exits 0 on success)

The flag requires noEmit: true (or Bundler resolution + a downstream tool that rewrites extensions). Otherwise TypeScript would emit JS that imports a .ts file at runtime, which fails.

Finding which file an import resolves to

When debugging "Cannot find module", ask the compiler exactly what it looked at.

bash
tsc --traceResolution 2>&1 | grep -A 2 'Loading module' | head

Output:

text
Loading module 'react' from 'src/App.tsx'.
======== Resolving module 'react' from '/repo/src/App.tsx'. ========
  Explicitly specified module resolution kind: 'NodeNext'.
  Loading module as file / folder, candidate module location '/repo/node_modules/react', target file types: TypeScript, JavaScript, Declaration.
  File '/repo/node_modules/react/package.json' exists according to earlier cached lookups.
  'package.json' has 'exports' field, looking up '/repo/node_modules/react/package.json'.

The output is verbose but it tells you exactly which node_modules path was tried and which condition matched. Indispensable for diagnosing dual-package hazards.