Skip to content
CodeMorph
Learn

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.

Advanced2 min readUpdated 11 October 2026

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.

tsconfig.json
{
  "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.ts

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

Next guideTyping React components

Let CodeMorph do the heavy lifting.

Download the beta, open a project and see your migration plan in under a minute.