Interoperabilidad ESM y CommonJS
El problema real de que dos sistemas de módulos convivan: el default interop, la detección de named exports, la asimetría al requerir ESM, el dual package hazard, y cómo Node y los bundlers lo resuelven.
Dos sistemas de módulos conviven en el ecosistema, y tarde o temprano un módulo de uno importa a otro. Ahí surge la fricción: el default interop y los named exports. La mayoría de errores tipo “X is not a function” o “does not provide an export named default” nacen justo aquí. Entender las reglas de Node y de los bundlers es dejar de sufrirlos.
- Importar CJS desde ESM: el default interop y la detección de named exports.
- Importar ESM desde CJS: la asimetría, y la solución con
import(). - Ver qué es el default interop y el papel del marcador
__esModule. - Reconocer el dual package hazard y cómo lo mitigan Node y los bundlers.
De ESM a CJS: el default interop
Cuando un módulo ESM importa uno CommonJS, el module.exports completo del CJS se expone como la exportación por defecto. Esa es la regla base:
// suma.cjs → module.exports = { sumar, PI }
import pkg from "./suma.cjs"; // pkg === module.exports entero
const { sumar } = pkg;
// named import: funciona SI Node detecta 'sumar' estáticamente
import { sumar } from "./suma.cjs";
Los named imports desde CJS no están garantizados por la especificación: Node usa cjs-module-lexer, un analizador sintáctico que intenta descubrir las claves de module.exports sin ejecutar el módulo. Si el módulo asigna sus exportaciones de forma demasiado dinámica, el lexer falla y solo queda el default.
La consecuencia práctica: importar un módulo CJS con import x from siempre funciona (recibes el objeto entero), mientras que import { y } from es la vía frágil. Ante la duda, muchas librerías documentan el default y dejan los named exports como cortesía cuando el lexer los detecta.
cjs-module-lexer reconoce patrones comunes —exports.x = ..., module.exports = { x, y }, reexports de otro require— pero no ejecuta nada. Si un módulo construye sus exportaciones en un bucle, tras una condición o con un Object.assign dinámico, el lexer no las ve y esos named imports fallan aunque en runtime existan. La salida del consumidor: importar el default y desestructurar a mano.
Los bundlers que transpilan ESM a CJS marcan sus salidas con Object.defineProperty(exports, "__esModule", { value: true }). Helpers como _interopRequireDefault leen ese flag para decidir si devolver .default o el objeto entero, emulando el default interop de Node. Ese es el origen de los .default.default que a veces aparecen al mezclar toolchains mal configurados.
En esencia, el helper decide con una comprobación como esta:
function interopDefault(mod) {
return mod && mod.__esModule ? mod.default : mod;
}
Cuando falta o sobra un __esModule en la cadena de transpilación, aparece el clásico mod.default.default o el default is not a function: nadie se puso de acuerdo sobre dónde estaba el valor real.
De CJS a ESM: la asimetría
La dirección inversa es más espinosa. Históricamente require() de un módulo ESM lanzaba ERR_REQUIRE_ESM, porque require es síncrono y la evaluación de ESM podía ser asíncrona (top-level await). La solución universal era diferir con import():
// desde un módulo CommonJS
async function cargar() {
const { render } = await import("./editor.mjs"); // import() sí funciona
render();
}
En 2026 Node estabilizó require(esm): permite requerir de forma síncrona módulos ESM que no usen top-level await. Si el módulo tiene TLA, require sigue fallando y hay que usar import(). La regla mental: ESM puede ser asíncrono, y no puedes esperar asíncronamente dentro de una función síncrona.
Este cambio, largamente esperado, desatasca miles de librerías CJS que necesitaban cargar dependencias ya migradas a ESM sin reescribirse enteras. Marca el punto de inflexión en que publicar solo ESM dejó de ser un riesgo de compatibilidad para volverse la opción por defecto recomendada.
flowchart LR E[modulo ESM] -->|import default| M[module.exports del CJS] E -->|named import| L[cjs-module-lexer detecta claves] C[modulo CJS] -->|require esm sin TLA| N[Node moderno lo permite] C -->|import dinamico| P[promesa del namespace] style E fill:#89b4fa,color:#11111b style C fill:#fab387,color:#11111b style P fill:#a6e3a1,color:#11111b
Las reglas de interoperabilidad, condensadas:
| Dirección | Regla en Node 2026 |
|---|---|
| ESM importa CJS (default) | module.exports completo es el default |
| ESM importa CJS (named) | funciona si cjs-module-lexer detecta la clave |
| CJS requiere ESM sin TLA | permitido con require(esm) |
| CJS requiere ESM con TLA | falla: hay que usar import() |
| Cualquiera importa ESM | siempre analizable estáticamente |
Observa la asimetría: importar ESM siempre es predecible, mientras que consumir CJS depende de heurísticas. Esa diferencia es la brújula de diseño en 2026 —cuanto más ESM haya en la cadena, menos casos límite aparecen— y explica por qué las herramientas empujan hacia allí.
El campo exports y las conditions
Una librería puede publicar ambos formatos y dejar que el consumidor reciba el adecuado, con las conditions de package.json:
{
"name": "mi-lib",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
Node y los bundlers eligen la rama import para un import de ESM y require para un require de CJS. Esto se llama dual package: un solo paquete que sirve a los dos mundos.
Antes de exports existían campos sueltos que aún verás en librerías vivas: main (entrada CJS clásica), module (una convención no oficial que apuntaba al build ESM para bundlers) y types (los tipos de TypeScript). Cuando exports está presente, prevalece sobre main y encapsula el paquete: solo lo que declares es importable desde fuera, cerrando el acceso a rutas internas. Es el mecanismo canónico en 2026.
En TypeScript 2026, el tsconfig.json de un proyecto con bundler usa "moduleResolution": "bundler": replica cómo Vite y Rolldown resuelven exports y conditions, sin la extensión obligatoria del modo nodenext. Alinear el type-checker con el resolver real del build evita el bug donde TypeScript aprueba un import que el bundler no encuentra.
Generar ese doble build a mano es tedioso y propenso a errores; en 2026 se delega en empaquetadores de librerías como tsdown, unbuild o el propio vite build --lib, que producen las variantes ESM y CJS, los .d.ts y el bloque exports coherente a partir de una sola configuración. Escribir los dos formatos a mano es un anti-patrón.
El dual package hazard
El dual package tiene una trampa sutil. Si un mismo paquete se carga a la vez como ESM y como CJS —por rutas distintas del grafo—, existirán dos copias con estado separado. Un objeto creado por la copia ESM fallará un instanceof contra la clase de la copia CJS, y cualquier singleton se duplicará:
El síntoma
instanceof falla, un caché se ignora, o una config global aparece vacía: hay dos instancias del mismo módulo.
La mitigación
Mantener el estado en un núcleo CJS fino que ambas caras reexportan, o publicar solo ESM y evitar el problema de raíz.
Herramientas como arethetypeswrong (attw) y publint auditan un paquete antes de publicarlo: detectan un exports mal formado, conditions que apuntan al formato equivocado o tipos que no casan con el runtime. En 2026 son parte estándar del CI de cualquier librería seria, precisamente porque los fallos de interop son silenciosos hasta que un consumidor los sufre.
La interoperabilidad ESM/CJS es el ejemplo canónico de abstracción con fugas: por más que Node y los bundlers la disimulen, la costura entre dos modelos incompatibles —uno estático con bindings vivos, otro dinámico con semántica de valor— asoma en cada caso límite. El default interop existe porque module.exports es un valor único y ESM necesita un lugar donde ponerlo: el default. La detección de named exports es heurística porque adivinar la forma de un módulo dinámico sin ejecutarlo es, en el caso general, indecidible. El dual package hazard surge porque la identidad de un módulo se define por su ruta resuelta, y dos formatos son dos rutas. Ninguna de estas reglas es arbitraria: cada una es la mejor conciliación posible de una tensión de fondo. Por eso el arreglo duradero no es memorizar los parches, sino que el ecosistema converja a ESM-only —cosa que en 2026 ya hacen las librerías nuevas—. Mientras tanto, quien entiende por qué existe cada regla depura en minutos lo que a otros les cuesta una tarde.
- Importa un módulo CJS desde ESM con default y con named imports; observa cuándo el segundo funciona y cuándo no.
- Intenta
require()de un.mjscon top-level await y relaciona el error con la naturaleza síncrona derequire. - Publica un paquete de prueba con conditions
importyrequirey comprueba qué rama recibe cada consumidor. - Fuerza el dual package hazard cargando el mismo paquete por las dos vías y verifica que un
instanceoffalla. - Empaqueta una librería mínima con tsdown o
vite build --liby examina elexportsque genera con sus conditionsimport,requireytypes.