Módulos ESM vs CommonJS
Entendiendo los dos sistemas de módulos de Node.js — cuándo usar cada uno, cómo migrar y cómo admitir ambos consumidores desde un único paquete.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
// package.json — declara este paquete como ESM
{
"name": "my-pkg",
"version": "1.0.0",
"type": "module"
}// ESM — import / export
import { readFile } from 'node:fs/promises';
export async function loadConfig(path) {
return JSON.parse(await readFile(path, 'utf8'));
}// CommonJS — require / module.exports
const { readFileSync } = require('node:fs');
module.exports.loadConfig = function (path) {
return JSON.parse(readFileSync(path, 'utf8'));
};// Import dinámico — funciona en AMBOS sistemas
const mod = await import('some-esm-only-package');// Usa createRequire para cargar CJS desde ESM
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('some-cjs-only-package');Extensiones explícitas:
.mjs— siempre ESM, independientemente depackage.json..cjs— siempre CommonJS, independientemente depackage.json..js— depende del campo"type"delpackage.jsonmás cercano.
Cuándo usarlo: Cada vez que encontres un error "ERR_REQUIRE_ESM", un error "Cannot use import statement outside a module", o estés decidiendo cómo publicar un paquete.
Ejemplo Funcional
Un script ESM que importa tanto un paquete ESM moderno como un paquete CJS heredado.
// package.json
{
"name": "mixed-modules",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"chalk": "^5.3.0",
"lodash": "^4.17.21"
}
}// src/index.js — entrada ESM que mezcla ambos sistemas de módulos
import chalk from 'chalk'; // paquete solo ESM
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readFile } from 'node:fs/promises';
// Reconstruir __dirname en ESM
const __dirname = dirname(fileURLToPath(import.meta.url));
// Cargar un paquete solo CommonJS desde un archivo ESM
const require = createRequire(import.meta.url);
const _ = require('lodash');
// Cargar JSON en ESM (sintaxis de atributos de importación)
const pkg = JSON.parse(
await readFile(join(__dirname, '..', 'package.json'), 'utf8'),
);
console.log(chalk.green(`Running ${pkg.name} v${pkg.version}`));
console.log(chalk.cyan('chunked:'), _.chunk([1, 2, 3, 4, 5], 2));Lo que esto demuestra:
- Un archivo de entrada ESM importando un paquete ESM moderno solo (
chalkv5+) createRequiredesdenode:modulepara cargar un paquete solo CommonJS desde ESM- El baile de
import.meta.url+fileURLToPathpara reconstruir__dirname - Leer JSON mediante
fspara evitar la sintaxis de atributos de importación aún en evolución - Top-level
await— solo legal en ESM
Profundización
Cómo Funciona
CommonJS fue el sistema de módulos original de Node.js. Cada archivo .js se envuelve en una función síncrona que recibe require, module, exports, __filename y __dirname. Porque es síncrono, require('x') se bloquea hasta que x se carga completamente.
Los módulos de ES (ES Modules) son el estándar de JavaScript. Son asíncronos, analizables estáticamente y admiten características que CommonJS nunca podría — top-level await, tree-shaking y enlaces en vivo. El trade-off es que require no está disponible dentro del alcance ESM, las extensiones de archivo son obligatorias y el gráfico de módulos se resuelve antes de que se ejecute cualquier código.
Node.js elige un sistema de módulos por archivo basándose en:
- Extensión explícita —
.mjses siempre ESM,.cjses siempre CommonJS. - De lo contrario, el campo
"type"delpackage.jsonpadre más cercano —"module"significa ESM,"commonjs"(o faltante) significa CommonJS.
Desde ESM siempre puedes cargar CommonJS con una importación por defecto o createRequire. Desde CommonJS no puedes require estáticamente un módulo ESM — debes usar import() dinámico, que devuelve una promesa.
Variaciones
Convertir un archivo CJS a ESM:
// Antes — CommonJS
const path = require('node:path');
const { readFile } = require('node:fs/promises');
module.exports.read = (p) => readFile(path.resolve(p), 'utf8');
// Después — ESM
import path from 'node:path';
import { readFile } from 'node:fs/promises';
export const read = (p) => readFile(path.resolve(p), 'utf8');Exportaciones de paquete dual — envía tanto ESM como CJS:
// package.json
{
"name": "dual-pkg",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}Importar JSON en ESM — usa la sintaxis de atributos de importación (escapando las llaves aquí porque esto es prosa markdown): import data from './data.json' with \{ type: 'json' \};. Está detrás de una bandera en versiones antiguas de Node, así que muchos proyectos aún prefieren readFile + JSON.parse.
Polyfill de __dirname en ESM:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));Notas de TypeScript
Para TypeScript, "module": "NodeNext" es casi siempre la opción correcta para código de Node.js. Esto:
- Respeta el campo
"type"delpackage.jsonmás cercano. - Obliga extensiones
.jsen importaciones relativas (aunque la fuente sea.ts). - Entiende las condiciones de
exports(import,require,types). - Distingue
.mts(fuerza ESM) y.cts(fuerza CJS).
"module": "ES2022" compila a ESM pero no obliga las reglas de resolución de Node — úsalo solo si un bundler consumirá el resultado.
Siempre empareja "module": "NodeNext" con "moduleResolution": "NodeNext". Cualquier otro modo de resolución divergirá silenciosamente de cómo Node realmente carga los archivos.
Trampas
- Olvidar extensiones de archivo en importaciones ESM. ESM requiere el especificador completo, incluyendo
.js.import './foo'lanzaERR_MODULE_NOT_FOUND. TypeScript conNodeNextobliga esto en tiempo de compilación. - Mezclar tipos de módulos en un proyecto. Un archivo
.jsen un paquete"type": "module"es ESM; su archivo hermano.cjses CommonJS. Esto funciona, pero los nombres e importaciones se confunden rápidamente — prefiere un sistema por paquete. - Las importaciones JSON necesitan atributos de importación.
import data from './data.json'lanza en Node moderno a menos que agregueswith { type: 'json' }(escapado aquí porque es markdown). En caso de duda,JSON.parse(await readFile(...)). requireno está disponible en el alcance ESM. Intentar llamarrequire('x')dentro de un archivo ESM lanzaReferenceError: require is not defined. UsacreateRequire(import.meta.url)cuando realmente lo necesites.__dirnamey__filenameno están definidos en ESM. Reconstruyelos desdeimport.meta.urlconfileURLToPath.require()-ing un paquete ESM. ObtienesERR_REQUIRE_ESM. Migra a ESM o usaimport()dinámico, que es asincrónico y devuelve una promesa."type": "module"afecta a cada archivo.jsdebajo. Agregar el campo voltea un proyecto completo de una vez. Renombra cualquier cosa que deba mantenerse CJS a.cjs.
Alternativas
| Runtime / Opción | Fortalezas | Debilidades |
|---|---|---|
| Node.js ESM | Estándar, a prueba de futuro, top-level await | Extensiones estrictas, más nuevas para algunas librerías |
| Node.js CJS | Maduro, funciona con cada librería | Sin top-level await, sin tree-shaking |
| Paquete dual (ESM + CJS) | Admite a cada consumidor | Configuración de compilación compleja, fácil de enviar tipos rotos |
| Bun | Admite ambos sistemas de módulos nativamente, muy rápido | Runtime diferente, ecosistema aún en maduración |
| Deno | Solo ESM, seguro por defecto, TS nativo | Resolución de módulos diferente, importaciones basadas en URL |
| Proyecto CJS-only heredado | Cero costo de migración | No puede usar librerías ESM modernas de forma estática |
Preguntas Frecuentes
¿Cómo le digo a Node.js que un archivo es ESM?
Nómbralo .mjs, o agrega "type": "module" al package.json más cercano. De lo contrario, Node trata archivos .js como CommonJS.
¿Puedo usar require en un archivo ESM?
No directamente. require es un global solo de CommonJS. Usa createRequire(import.meta.url) desde node:module si realmente necesitas cargar un módulo CJS desde ESM.
¿Puedo usar import en un archivo CommonJS?
Solo la forma dinámica: const mod = await import('some-pkg'). Las sentencias estáticas de import solo funcionan en archivos ESM.
¿Cuál es la diferencia entre .mjs, .cjs y .js?
.mjs es siempre ESM, .cjs es siempre CommonJS, y .js depende del campo "type" del package.json más cercano. Si "type" falta, .js por defecto es CommonJS.
¿Por qué obtengo ERR_REQUIRE_ESM? (trampa)
Porque CommonJS require() no puede cargar un paquete ESM de forma síncrona. Convierte tu archivo a ESM, o usa import() dinámico que devuelve una promesa.
¿Por qué mi importación falla con ERR_MODULE_NOT_FOUND? (trampa)
Casi siempre una extensión de archivo faltante. ESM requiere el especificador completo — escribe ./foo.js, no ./foo. CommonJS es más permisivo y agrega la extensión por ti.
¿Debo establecer "module": "NodeNext" o "ES2022" en tsconfig.json? (typescript)
Usa "NodeNext" para cualquier código que Node.js ejecute directamente. Obliga las reglas de resolución reales de Node, incluyendo extensiones .js obligatorias y el campo exports. Usa "ES2022" solo cuando un bundler (Vite, webpack, esbuild) procesa el resultado.
¿Por qué TypeScript quiere que importe ./foo.js cuando el archivo es ./foo.ts? (typescript)
Porque NodeNext modela el especificador de runtime. En tiempo de ejecución Node carga foo.js — ya sea compilado desde foo.ts, o resuelto de vuelta a foo.ts por tsx/ts-node. Escribir ./foo.js es la única forma que funciona en ambos casos.
¿Cómo obtengo __dirname dentro de un script ESM?
Reconstruyelo desde import.meta.url:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));¿Cómo importo un archivo JSON en ESM?
Usa atributos de importación — import data from './data.json' with { type: 'json' } — o léelo y analízalo tú mismo con fs.readFile y JSON.parse. Este último evita problemas de compatibilidad de bandera en versiones de Node.
¿Funciona el top-level await en CommonJS?
No. El top-level await es una característica solo de ESM. En CommonJS debes envolver código asincrónico en una función async y llamarla.
¿Qué es el campo exports en package.json?
Es la forma moderna de declarar los puntos de entrada públicos de un paquete. Admite exportaciones condicionales — diferentes archivos para import, require, types, browser, y así sucesivamente — y oculta archivos que no quieres que los consumidores accedan.