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

      1

      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.

      2

      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.

      3

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

      4

      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.

      5

      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 CommonJSES Modules engine will do. It isn't live yet.

    ConcernManual / DIYRefactyl (roadmap)
    Static analysis friendlinessCJS: bundlers can't tree-shake require() calls reliablyESM: bundlers statically analyse imports for dead code elimination
    Extension requirementManual .js extension addition to every relative importWill add the extension across all import statements
    Dynamic require() conversionEach dynamic require identified and async-promoted manually, with a risk of missing oneWill detect every dynamic require and apply async promotion with call-chain tracing
    Verification gatenode --input-type=module errors surface one file at a timeFull 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 CommonJSES 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.

    Join the waitlist →

    Refactyl doesn't migrate CommonJS to ES Modules yet. No misleading trial, no fake demo.