Migration guide
CommonJS → ES Modules, step by step.
ES Modules are the standard now. Node, browsers, and bundlers all run them natively. Most of the change is mechanical: require() becomes import, module.exports becomes export, __dirname gets a two-line shim. Then there's the part that actually costs you time: dynamic require() and circular dependencies. Here's every step, including those.
Refactyl doesn't migrate CommonJS to ES Modules yet. This guide covers the manual path; the waitlist notifies you when the verified engine ships.
Why this migration is hard to get right
Dynamic require() has no direct ESM equivalent
`const mod = require(computedPath)` evaluates at runtime. The ESM equivalent, `await import(path)`, is async, so any function calling dynamic require must either become async or be refactored to accept pre-loaded modules. In deeply nested call stacks, this async promotion can cascade upward through multiple function signatures.
__dirname and __filename don't exist in ESM
CJS provides __dirname (directory of current file) and __filename (full path) as free variables. ESM has neither. The equivalent uses import.meta.url and Node's fileURLToPath: `const __dirname = path.dirname(fileURLToPath(import.meta.url))`. Every path.join(__dirname, ...) construction needs this two-line preamble.
Circular imports behave differently
CJS resolves circular require() lazily: module A can require module B which requires module A, getting an incomplete A if A hasn't finished evaluating yet. ESM uses live bindings: circular imports see the exported binding (initially undefined), not a partially-constructed module. Circular dependency bugs that were hidden by CJS lazy resolution surface as 'used before initialization' errors.
How to migrate manually
Add "type: module" to package.json and handle .mjs/.cjs
Set `"type": "module"` in package.json. This makes .js files ESM by default. Files that must stay CJS (config files that tools require() synchronously) should be renamed .cjs. Check that Jest, Prettier, ESLint, and other tooling configs are .cjs or load them with require() from a .cjs wrapper.
Replace require() with import
`const foo = require('./foo')` → `import foo from './foo'`. `const { bar } = require('./baz')` → `import { bar } from './baz'`. `const pkg = require('../package.json')` → `import pkg from '../package.json' assert { type: 'json' }` (or use createRequire for compatibility). Add explicit .js extensions to relative imports, because ESM requires them.
Replace module.exports with export
`module.exports = { foo, bar }` → `export { foo, bar }`. `module.exports = function() {}` → `export default function() {}`. Named exports on module.exports → named export declarations. Watch for reassignment patterns (`module.exports.thing = ...` added after the initial export).
Fix __dirname and __filename
Add at the top of each file that uses them: `import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);`. Then usage continues unchanged.
Handle dynamic require() and circular dependencies
Convert synchronous dynamic require() to async import() and make the calling function async. For circular imports: restructure to break the cycle (extract shared code to a third module), or use a lazy import pattern where one direction imports at call time, not module load time. Run node --experimental-vm-modules or the TypeScript compiler to surface remaining issues.
What Refactyl's verified version will do
Roadmap
Refactyl doesn't run this migration yet. When it ships, the bar will be the same as every other Refactyl path: every file gated by a real node --input-type=module / tsc with module: node16 run, repaired until clean, or clearly flagged with the reason. No unverified files ship.
The public benchmark methodology is at refactyl.com/benchmark . The same dual-metric approach will apply here.
Manual / DIY vs Refactyl (roadmap)
This table describes what Refactyl's CommonJS→ES Modules engine will do. It isn't live yet.
| Concern | Manual / DIY | Refactyl (roadmap) |
|---|---|---|
| Static analysis friendliness | CJS: bundlers can't tree-shake require() calls reliably | ESM: bundlers statically analyse imports for dead code elimination |
| Extension requirement | Manual .js extension addition to every relative import | Will add the extension across all import statements |
| Dynamic require() conversion | Each dynamic require identified and async-promoted manually, with a risk of missing one | Will detect every dynamic require and apply async promotion with call-chain tracing |
| Verification gate | node --input-type=module errors surface one file at a time | Full module graph will be verified before delivery |
FAQ
CommonJS → ES Modules migration: common questions
Real questions engineers ask before migrating CommonJS to ES Modules, answered honestly.
On the roadmap
Want Refactyl to do this for you?
Join the waitlist and we'll notify you when the CommonJS→ES Modules engine ships. The bar is the same as every other Refactyl path: every file gated by a real node --input-type=module / tsc with module: node16 run before handoff.
Refactyl doesn't migrate CommonJS to ES Modules yet. No misleading trial, no fake demo.
Other migrations