wandres.dev
MÓDULOS JS · ESM vs CommonJS

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.

⏱ 17 min

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.

🎯 Al terminar esta lección sabrás
  • 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.

📝
Por qué el lexer a veces falla

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.

ℹ️
El marcador __esModule

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.

💡
moduleResolution: bundler

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 interop es una abstracción con fugas

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.

⚔️ Provoca y resuelve la fricción
  1. Importa un módulo CJS desde ESM con default y con named imports; observa cuándo el segundo funciona y cuándo no.
  2. Intenta require() de un .mjs con top-level await y relaciona el error con la naturaleza síncrona de require.
  3. Publica un paquete de prueba con conditions import y require y comprueba qué rama recibe cada consumidor.
  4. Fuerza el dual package hazard cargando el mismo paquete por las dos vías y verifica que un instanceof falla.
  5. Empaqueta una librería mínima con tsdown o vite build --lib y examina el exports que genera con sus conditions import, require y types.