1. Node.js ESM / CJS rules
| File being imported | Node module type | Import syntax | Extension required? | Notes |
|---|---|---|---|---|
.js |
ESM | import |
✅ Yes | Relative ESM imports must include .js. Directory imports are not allowed. |
.mjs |
ESM | import |
✅ Yes | Explicit .mjs is required if you’re targeting ESM in Node. |
.cjs |
CJS | require |
✅ Optional | Node can resolve .cjs without extension. In ESM, you must use .cjs if you import. |
.ts (source) |
N/A | import (TS source) |
❌ Do not use .ts at runtime |
TS compiler resolves .js specifiers back to .ts source. Runtime only sees .js. |
.mts (TS ESM) |
ESM | import |
✅ Yes (use .js in runtime) |
Source file extension .mts is TypeScript-only; runtime uses .js. |
.cts (TS CJS) |
CJS | require |
✅ Optional | Same as above; TS source, runtime is .js. |
Key rules by context
1. Relative imports in ESM (Node)
- Always include explicit file extension:
.js/.mjs/.cjs. - Example:
import { helper } from "./utils/helper.js"; // ✅ - Directory imports do not work:
import { helper } from "./utils"; // ❌ ERR_UNSUPPORTED_DIR_IMPORT
2. Relative imports in CJS (Node)
.jsor.cjsextensions are optional. Node will resolve:./module.js ./module.cjs ./module/index.js ./module/index.cjs- Example:
const helper = require("./utils"); // ✅ Node will find ./utils/index.js
3. TypeScript source files (.ts, .mts, .cts)
- Always write runtime-compatible extensions (
.js) in import specifiers, even if importing.ts.import { helper } from "./utils/helper.js"; // ✅ TS resolves this to ./utils/helper.ts - Node never sees
.ts,.mts,.cts. These are compiled away.
4. Dynamic imports
- Same rule: use the runtime extension (
.js) in the specifier. - Example:
const mod = await import("./feature.js");
2. When to use which extension
| Import type | Use in source | Runtime seen by Node |
|---|---|---|
.ts |
Never in import |
Node doesn’t see .ts |
.mts |
Source file | Compiled to .js at runtime |
.cts |
Source file | Compiled to .js at runtime |
.js |
Runtime file | Node sees .js |
.cjs |
Runtime file | Node sees .cjs |
.mjs |
Runtime file | Node sees .mjs |
3. Summary guidelines
- Always write the runtime extension in TypeScript source imports:
.js for .ts/.mts source targeting ESM
.cjsfor.ctssource targeting CJS- Never import
.tsor.mtsat runtime. Node cannot run them. - Directory imports only work in CJS, not in ESM. For ESM, always point to
/index.js. - Dynamic imports follow the same rule: use runtime extension.
- Use
"exports"or"imports"inpackage.jsonto avoid writing/index.jsin public APIs.
If you want, I can make a visual map showing all 6 file extensions, what TypeScript sees, and what Node sees, with arrows showing what extension to use in each import. It’s a really handy cheat sheet. Do you want me to do that?