CommonJS to ES modules during a TypeScript migration
Decide whether to switch module systems, configure TypeScript for Node's ESM rules, and fix the handful of patterns that break along the way.
Many JavaScript backends are CommonJS: require and module.exports. A TypeScript migration is a natural moment to move to ES modules, but you don't have to do both at once. Decide deliberately.
Option A: write ESM syntax, keep CommonJS output
The low-risk path. You write import and export in TypeScript, and the compiler emits CommonJS. Nothing changes at runtime.
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true
}
}With NodeNext, TypeScript follows Node's own rules: if your package.json has no "type": "module", .ts files compile to CommonJS.
Option B: go fully ESM
Add "type": "module" to package.json. Now .ts files compile to real ES modules, and Node's ESM rules apply.
Relative imports need extensions
Node's ESM loader doesn't guess file extensions. You write the output extension, even though the source file is .ts:
import { db } from "./lib/db.js"; // resolves to src/lib/db.tsIt looks odd the first time, and it's correct. TypeScript maps .js back to .ts during type-checking.
__dirname and __filename are gone
import { fileURLToPath } from "node:url";
import path from "node:path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));On Node 20.11 and later you can use import.meta.dirname directly.
require is gone too
For the rare synchronous require, create one:
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const legacy = require("./legacy.cjs");JSON imports need an attribute
import config from "./config.json" with { type: "json" };Patterns that break during conversion
module.exports = function. A single exported function becomes a default export:
// before
module.exports = function hashPassword(pw) { /* ... */ };
// after
export default function hashPassword(pw: string): string { /* ... */ }Mixed exports. module.exports = fn; module.exports.helper = h; becomes a default export plus a named export. Update every importer.
The .default trap. A CommonJS file that still requires a converted module gets the namespace object, so it needs require("./auth").default. Convert importers in the same batch as the module, or keep them on named exports.
Mixed folders. During the transition you can mark individual files with .cjs or .mts to pin their module format.
Why order matters here too
Converting module syntax changes how importers must reference a file. Go leaf-first and each file's importers are converted after it, in the same pass, so you never leave a .default mismatch behind. CodeMorph records CommonJS and ESM edges in the same dependency graph and plans the order for you.