commonjs-to-esm
Migrates Node.js CommonJS (.js, .cjs) to ES modules.
Relative imports get an explicit .js suffix where appropriate for Node ESM resolution.
TypeScript is out of scope for this package. TS CommonJS uses different syntax (import x = require(...), export =, import type, etc.) and different tooling (tsconfig / emit). Plan a separate codemod or pipeline (e.g. compile to JS then run this tool, or a dedicated commonjs-to-esm-typescript package) for .ts sources.
What it does
1. require() → import
| Before | After |
|---|---|
| const x = require('mod') | import x from 'mod' |
| var x = require('mod') | import x from 'mod' |
| const { a, b } = require('mod') | import { a, b } from 'mod' |
| const { a: alias } = require('mod') | import { a as alias } from 'mod' |
| require('./setup') | import './setup' |
| const join = require('path').join | import { join } from 'path' |
| const fs = require('node:fs/promises') | import fs from 'node:fs/promises' |
| const legacy = require('./x.cjs') | import legacy from './x.cjs' (existing extension preserved) |
Multiline destructuring is fully supported.
2. module.exports / exports → export
| Before | After |
|---|---|
| module.exports = expr | export default expr |
| module.exports = function fn() {} | export default function fn() {} |
| module.exports = require('./other') | export { default } from './other.js' |
| module.exports.name = name | export { name } |
| module.exports.name = other | export { other as name } |
| module.exports.PI = 3.14 | export const PI = 3.14 |
| exports.name = name | export { name } |
| exports.name = expr | export const name = expr |
| exports.default = expr | export default expr (fixes invalid export const default) |
| module.exports.default = expr | export default expr |
3. Named re-export pattern (from Node.js official guide)
| Before | After |
|---|---|
| exports.foo = require('./foo') | export { default as foo } from './foo.js' |
| exports.bar = require('./bar') | export { default as bar } from './bar.js' |
| module.exports.utils = require('./utils') | export { default as utils } from './utils.js' |
4. __dirname / __filename → import.meta
| Before | After |
|---|---|
| __dirname | import.meta.dirname |
| __filename | import.meta.filename |
Requires Node.js >= 21.2.0 (or >= 20.11.0 LTS).
5. require.resolve() → import.meta.resolve()
| Before | After |
|---|---|
| require.resolve('./config') | import.meta.resolve('./config') |
| require.resolve('express') | import.meta.resolve('express') |
| require.resolve('./a', { paths: [__dirname] }) | unchanged (second-argument paths is not equivalent to import.meta.resolve) |
Only single-argument require.resolve(...) calls are rewritten. Multi-argument forms are skipped per Node.js migrating imports.
Requires Node.js >= 20.6.0. Returns a URL string; some call sites may need fileURLToPath() wrapping.
6. JSON imports with type attribute
| Before | After |
|---|---|
| const pkg = require('./package.json') | import pkg from './package.json' with { type: 'json' } |
| const cfg = require('./config.json') | import cfg from './config.json' with { type: 'json' } |
Uses the with syntax (Node.js 21+). For Node.js 17.1-20.x, manually change with to assert.
7. "use strict" removal
ESM runs in strict mode by default. The codemod removes top-level "use strict" directives automatically.
8. Dynamic require() → dynamic import()
| Before | After |
|---|---|
| const mod = require(variable) | const mod = await import(variable) |
Only top-level dynamic requires with non-string arguments are converted.
9. Automatic .js extension for relative imports
| Before | After |
|---|---|
| require('./utils') | import './utils.js' |
| require('./data.json') | import './data.json' with { type: 'json' } (extension preserved) |
| require('express') | import express from 'express' (bare specifier, no extension added) |
Relative require('./folder') becomes ./folder.js. If CommonJS actually resolved a directory to ./folder/index.js, you must fix that path manually (directory imports, esmodules.com).
Patterns intentionally left unchanged (safety)
The codemod is conservative — it will NOT transform patterns that could produce incorrect code:
| Pattern | Why it's skipped |
|---|---|
| const { a, ...rest } = require('mod') | Rest patterns have no ESM import equivalent |
| require('x').a.b.c (deep chains) | Ambiguous — can't safely map to a single named import |
| require('express')() (immediately invoked) | Chained call after require is not a simple import |
| { ...require('./defaults') } (spread in object) | Nested require in expression position |
| exports = { ... } (direct reassign) | Reassigning exports is a CJS no-op, not a real export |
| module.exports inside if/try/functions | Conditional exports can't safely become static ESM |
| require() inside functions/loops/conditionals | Non-top-level requires are left for manual conversion |
| require.main === module | No clean ESM equivalent; left for manual refactoring |
| require.resolve(spec, { paths: [...] }) | Skipped — options differ from import.meta.resolve |
| module.parent | No ESM equivalent; left for manual follow-up |
| require.cache / require.extensions | Not available in ESM; hot-reload / loader hooks need a different design |
| Top-level this | In CJS, top-level this is exports; in ESM it is undefined — not rewritten (pawelgrzybek.com) |
Usage
From the registry
bash
Dry run (preview changes)
bash
Manual follow-up after running
- Add "type": "module" to your package.json
- Update package.json exports field — replace "main" with "exports"
- Directory imports — ./utils may need to become ./utils/index.js
- import.meta.resolve() returns URLs — some call sites may need fileURLToPath() wrapping
- Named imports from CJS packages may fail — use import pkg from 'cjs-pkg' then destructure
- Circular dependencies — ESM handles these differently from CJS
- Dynamic requires inside functions — left as require() calls; refactor to async patterns or use createRequire(import.meta.url)
- Test runner config — update Jest/Vitest configuration for ESM
Metrics
| Metric | Description |
|---|---|
| require-to-import | require() calls converted to import |
| exports-to-export | module.exports/exports converted to export |
| dirname-filename-replaced | __dirname/__filename replaced with import.meta |
| dynamic-require-to-import | Dynamic require() converted to import() |
| require-resolve-to-import-meta | require.resolve() converted to import.meta.resolve() |
| use-strict-removed | "use strict" directives removed |
| json-import-with-attribute | JSON imports with with { type: 'json' } attribute added |
License
MIT