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:
- Emit shape — what JS module format should the output use? CommonJS
require()? Native ESMimport? Or should it preserve whatever the source wrote? - 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.tsextension 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.
tsc --showConfig
Output:
{
"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.
{
"compilerOptions": {
"module": "NodeNext"
}
}
Output: (none — exits 0 on success)
| Value | Emits | Use when |
|---|---|---|
CommonJS | require() / module.exports | Legacy Node, no ESM consumers |
ES2015 / ES2020 / ESNext | import / export (no interop) | Bundlers that handle Node module field maps themselves |
Node16 | ESM or CJS based on file extension and package.json "type" | Node 16+ projects pinned to that semantics |
NodeNext | Same as Node16 but tracks the latest Node ESM rules | Modern Node 18+ libraries and applications |
Preserve | Keeps 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.
// 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.
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}
Output: (none — exits 0 on success)
| Value | Behaviour |
|---|---|
Classic | Legacy TS 1.x algorithm. Do not use. |
Node | Original Node-style resolution: ./lib → ./lib.ts, ./lib/index.ts, walk up node_modules. CJS-flavoured. |
Node10 | New name for Node (TS 5.0+). Same algorithm. |
Node16 / NodeNext | Native ESM resolution: requires explicit .js extension on relative imports, honours package.json "exports" field. |
Bundler | Vite/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.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src"]
}
// 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.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}
// 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.
// 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 ext | Emits | Forces |
|---|---|---|
.ts | .js | Whatever package.json "type" says |
.mts | .mjs | ESM |
.cts | .cjs | CJS |
.tsx | .js (with JSX transform) | Same as .ts for module shape |
Use .cts for a CommonJS bootstrap file in an ESM package:
// 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`);
tsc && node scripts/seed.cjs
Output:
loaded 42 users
Use .mts for an ESM utility in a legacy CJS package:
// scripts/check-url.mts — ESM utility in a CJS package
const res = await fetch('https://example.com');
console.log(res.status);
tsc && node scripts/check-url.mjs
Output:
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.
// 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:
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
NodetoNodeNext, runtsconce and fix every "Cannot find module" by appending.jsto the specifier. ESLint'simport/extensionsrule 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.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@ui/*": ["src/components/ui/*"],
"@lib/*": ["src/lib/*"]
}
}
}
In Vite, mirror the same mapping in vite.config.ts:
// 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.
node --import tsconfig-paths/register dist/index.js
Output: (none — exits 0 on success)
pathsis only honoured bymoduleResolution: Bundler,Node,Node10, orNode16/NodeNextwhen 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.
{
"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:
{
"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:
{
"references": [
{ "path": "./packages/ui" },
{ "path": "./packages/shared" }
]
}
tsc --build
Output:
[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.
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true,
"isolatedModules": true
}
}
// 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
verbatimModuleSyntaxis incompatible withmodule: CommonJSwhen you writeimport/exportsyntax. The flag assumes ESM emit. Use it withNodeNext,ESNext, orPreserve.
Common errors and what they really mean
The error messages around modules are notoriously cryptic. Here's the decoder ring.
tsc src/app.ts
Output:
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.
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'.
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).
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
- Mismatched
moduleandmoduleResolution— symptoms are phantom missing modules. Always set both explicitly to the same family (NodeNext/NodeNext,ESNext/Bundler). - Forgetting
.json relative imports under NodeNext — TS does not rewrite specifiers. The source file is.tsbut the import must read.js. pathsworks in editor but breaks at runtime — the editor uses TypeScript'spaths; Node doesn't. Add a runtime resolver or mirror the alias in the bundler.- Using
.mts/.ctsto "fix" a missing-default error — extension only changes module shape, not the contents. The fix is in the import / export form. - Adding
allowImportingTsExtensions: trueto a NodeNext config — only allowed withnoEmit: true(orBundlerresolution). Otherwise TS refuses to start. module: NodeNextwith an ancienttarget: ES5— produces invalid output (CJS files trying to use ES5 syntax in async contexts). BumptargettoES2022or later.- Symlinks bypass
paths— pnpm's symlinkednode_modulessometimes confusespaths. SetpreserveSymlinks: false(the default) and configure the bundler to match. extendsfrom a non-installed package —@tsconfig/node20must be indevDependencies. CI fails because the base config isn't present.isolatedModulescomplains aboutconst enum— Babel and esbuild can't see across files to inline the values. Convert to a regularenumor aconstobject pattern.exportsinpackage.jsonblocks 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.
// 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
}
}
// tsconfig.node.json — runs in Node directly
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
// 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:
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.
// src/index.mts — the ESM entry
export { compute } from './compute.js';
export type { ComputeOptions } from './compute.js';
// src/index.cts — the CJS shim
const { compute } = require('./compute.js');
module.exports = { compute };
{
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
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.
# 1. flip the compiler options
git diff tsconfig.json
Output:
- "module": "CommonJS",
- "moduleResolution": "Node",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "verbatimModuleSyntax": true,
# 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)
# 3. let tsc enumerate the broken imports
tsc --noEmit 2>&1 | grep "TS2307" | head
Output:
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.
# 4. autofix .js extensions with ESLint
npx eslint --fix --rule 'import/extensions: ["error", "always"]' src
Output:
3 problems (3 errors, 0 warnings)
3 errors and 0 warnings potentially fixable with the `--fix` option.
# 5. rerun the compiler
tsc --noEmit && echo "clean"
Output:
clean
A monorepo with project references
Two packages and an app. Each sub-package is composite: true; the root config references them.
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
// packages/shared/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
// apps/web/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true
},
"include": ["src"],
"references": [
{ "path": "../../packages/ui" },
{ "path": "../../packages/shared" }
]
}
tsc --build apps/web
Output:
[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:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true
}
}
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.
tsc --traceResolution 2>&1 | grep -A 2 'Loading module' | head
Output:
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.