learning-front

Difícil A · Ingeniería a escala: monorepos y publicación

ESM vs CommonJS a fondo

Los dos sistemas de módulos que conviven en el ecosistema; interop, dual package hazard y el campo exports.

Dos sistemas de módulos#

El ecosistema JavaScript tiene dos sistemas de módulos que conviven desde hace años: ESM (ECMAScript Modules, el estándar) y CommonJS (CJS, el sistema histórico de Node). No es que uno haya sustituido al otro: los dos están en producción hoy mismo, y saber cómo interactúan —y cuándo dan guerra— es parte del trabajo diario en cualquier proyecto que tire de dependencias de npm.

ESM es lo que llevas usando desde el primer capítulo del curso: import y export. CommonJS es lo que encontrarás en librerías más antiguas, en código de configuración (.eslintrc.cjs, jest.config.cjs) y en paquetes que todavía no han migrado. A veces conviven en el mismo proyecto, y cuando lo hacen hay reglas claras que hay que conocer.


CommonJS#

CommonJS nació para Node.js antes de que ESM existiera. Su sintaxis es diferente y su modelo de carga es radicalmente distinto.

// heroes.js — módulo CommonJS que exporta una lista y una función
// module.exports es el objeto que recibe quien haga require() de este fichero.
module.exports = {
// La lista de héroes disponibles.
lista: ["Reinhardt", "Ana", "Tracer"],

// Devuelve solo los héroes del rol indicado.
filtrarPorRol: function (heroes, rol) {
  return heroes.filter(function (h) {
    return h.rol === rol;
  });
},
};
// equipo.js — módulo que carga heroes.js con require()
// require() es síncrono: bloquea la ejecución hasta que el módulo está cargado.
const heroes = require("./heroes");

// Accedemos a la propiedad lista del objeto exportado.
console.log("Héroes disponibles: " + heroes.lista.join(", "));

Algunas diferencias respecto a ESM que verás al trabajar con código CJS:

  • require() puede aparecer en cualquier punto del código, incluso dentro de un if o una función. Los imports de ESM solo pueden ir al nivel superior del fichero.
  • module.exports es el objeto que recibe el consumidor. Si lo sustituyes por completo (module.exports = { ... }), el objeto anterior queda descartado.
  • __dirname y __filename son variables automáticas disponibles en CJS que dan la ruta del directorio y del fichero actual. En ESM no existen; el equivalente es import.meta.url.
  • CJS usa una caché por ruta: si dos módulos hacen require("./heroes"), obtienen la misma instancia en memoria. ESM también cachea, pero la clave es la URL resuelta.

También existe una variante más concisa con exports en vez de module.exports:

// roles.js — variante con exports (alias de module.exports)
// Aquí añadimos propiedades al objeto exports en vez de sustituirlo entero.
exports.ROLES = ["Tanque", "Daño", "Apoyo"];

// Este export nombrado coexiste con el anterior en el mismo módulo.
exports.esRolValido = function (rol) {
return exports.ROLES.includes(rol);
};

Atención al error clásico: si mezclas module.exports = algo con exports.propiedad = algo en el mismo fichero, el module.exports gana y los exports.* se pierden. Son la misma referencia hasta que sustituyes module.exports.


ESM frente a CommonJS#

La diferencia que más importa en el día a día no es la sintaxis, sino el modelo de bindings.

Snapshot vs enlace vivo. Cuando haces const { lista } = require("./heroes"), estás copiando el valor de lista en ese instante. Si el módulo heroes.js modifica lista después, tu copia no lo ve. En ESM, import { lista } from "./heroes" no copia: crea un enlace vivo al binding del módulo. Si el módulo reasigna lista, tu import lo refleja en tiempo real.

Esto rara vez importa con valores primitivos o arrays que no se reasignan. Pero con bindings mutables —contadores, acumuladores, estado de módulo— la diferencia es observable y puede ser la causa de un bug silencioso.

Estático vs dinámico. ESM es estático: el motor procesa todos los import antes de ejecutar una sola línea de código. Eso permite a los bundlers hacer tree-shaking en build time (eliminar lo que nadie usa) porque saben exactamente qué se importa y qué no. CJS es dinámico: require() se ejecuta en el orden en que aparece, lo que impide el análisis estático.

Síncrono vs asíncrono. require() es síncrono y bloqueante: detiene la ejecución hasta que el módulo está listo. La carga de ESM es asíncrona por diseño (aunque en la práctica los imports estáticos se resuelven antes del arranque, el motor los puede cargar en paralelo).

Hoisting de imports. Las declaraciones import se hoist al principio del fichero, igual que las declaraciones de función. Puedes poner el import al final del fichero y funcionará, aunque por convención van arriba. Los require() no se hoist: se ejecutan exactamente donde están.


type: module y las extensiones .mjs/.cjs#

Node decide si un fichero .js es ESM o CJS según estas reglas, en orden:

{
"name": "@team-builder/core",
"type": "module"
}

Con "type": "module" en el package.json, todos los .js del paquete son ESM. Sin ese campo (o con "type": "commonjs"), todos los .js son CJS.

Las extensiones tienen prioridad sobre type:

  • .mjs — siempre ESM, independientemente del type del paquete.
  • .cjs — siempre CJS, independientemente del type del paquete.

Esto es útil para los paquetes que necesitan publicar los dos formatos: el build ESM va en .mjs y el CJS en .cjs, y coexisten en el mismo paquete sin que type cause conflictos.

Los ficheros de configuración de herramientas (eslint.config.cjs, jest.config.cjs, prettier.config.cjs) usan .cjs precisamente porque la herramienta los carga con require() aunque el proyecto sea ESM.


Interop#

ESM importando CJS#

Un módulo ESM puede importar un módulo CJS. Node hace la interop automáticamente:

// El import default de un módulo CJS recibe module.exports entero.
import heroes from "./heroes.cjs";

// heroes es el objeto { lista, filtrarPorRol } que exports.js asignó a module.exports.
console.log("Lista: " + heroes.lista.join(", "));

Los named imports (import { lista } from "./heroes.cjs") también funcionan en muchos casos: Node analiza el module.exports del CJS e intenta detectar propiedades nombradas. Pero no siempre es fiable (si el módulo CJS construye sus exports dinámicamente, la detección falla), así que en código propio es mejor importar el default y desestructurar a mano.

CJS importando ESM#

Aquí la dirección inversa es más delicada. require() es síncrono, así que históricamente no podía cargar ESM: lanzaba ERR_REQUIRE_ESM. Eso cambió: desde Node 22.12 —y con backport a 20.19— require() puede cargar un módulo ESM sin ningún flag, con una condición: ni ese módulo ni ningún módulo de su grafo debe contener top-level await. (En Node 22.0–22.11 la función existía pero detrás del flag --experimental-require-module; desde Node 23 va sin flag.)

// Node 22.12+ / 20.19+: require de un módulo ESM sin top-level await.
// Devuelve el Module namespace object (el equivalente al import * as).
const mod = require("./modulo-esm.mjs");

// El export default aparece como mod.default.
console.log("Default: " + mod.default);

// Los named exports aparecen como propiedades del namespace.
console.log("Named: " + mod.nombrado);

Si el módulo ESM (o cualquier módulo de su grafo) tiene top-level await, require() lanza un error distinto: ERR_REQUIRE_ASYNC_MODULE. La diferencia es sutil pero importa, y es la que separa los dos escenarios:

  • ERR_REQUIRE_ESMrequire(esm) no está disponible en esa versión o configuración de Node (versiones viejas, o require() de ESM deshabilitado). Ni lo intenta.
  • ERR_REQUIRE_ASYNC_MODULErequire(esm) sí está disponible, pero el módulo destino es asíncrono (tiene top-level await) y un require() síncrono no puede esperar a que resuelva.

Y aquí está la trampa que muerde en producción: ERR_REQUIRE_ASYNC_MODULE puede saltarte aunque tu código no tenga un solo await. Basta con que una dependencia transitiva —un paquete del que depende otro paquete del que dependes tú— sea ESM con top-level await y que en algún punto se cargue con require(). Te enteras por un stack trace que apunta a node_modules, no a tu código, y suele aparecer justo al actualizar una dependencia que ha migrado a ESM. No es un fallo tuyo: es el grafo de módulos ajeno cruzando la frontera CJS/ESM.

Corolario del versionado: la vieja regla «Node 18/20 siempre lanza ERR_REQUIRE_ESM» ya no vale. Node 18 está fuera de mantenimiento, y Node 20 a partir de 20.19 trae require(esm) sin flag. La versión concreta decide qué error verás.


Dual package hazard#

Cuando un paquete npm publica a la vez una build CJS y una build ESM, abre la puerta a un fallo difícil de depurar: el dual package hazard.

El escenario: tu aplicación usa import (import { Config } from "@team-builder/core") y una dependencia tuya usa require (const { Config } = require("@team-builder/core")). Node carga el módulo ESM para tu código y el módulo CJS para la dependencia. En el mismo proceso, hay dos copias del paquete en memoria.

Lo que ves: el código no da error. Aparentemente todo funciona.

Lo que falla en silencio: si el paquete exporta una clase y haces instancia instanceof Config, el resultado puede ser false aunque instancia sí sea una instancia de Config. El motivo es que la clase que usaste para crear instancia viene de la copia ESM, y la Config contra la que comparas en el instanceof viene de la copia CJS. Son dos objetos distintos, con prototipos distintos.

Lo mismo ocurre con singletons: si el paquete mantiene un estado global (un registro, una caché, un pool de conexiones), ese estado se duplica. Dos partes de tu app pueden hablar con instancias distintas del mismo singleton y no verse.

¿Cuándo muerde? Cuando usas librerías que exponen clases o que mantienen estado global. Librerías de validación, ORMs, sistemas de inyección de dependencias, loggers configurables. Librerías de utilidades puras sin estado normalmente no sufren el hazard aunque se carguen dos veces.

Mitigaciones:

  1. El campo exports con conditional exports (lo veremos a continuación): declarar exactamente qué fichero se carga en cada contexto reduce las posibilidades de doble carga.
  2. Publicar solo en ESM: si el paquete no tiene build de CJS, no puede haber dos copias. Ahora que Node moderno permite require() de un módulo ESM (sin top-level await), un paquete ESM-only se consume también desde CJS, así que esta es la mitigación más robusta cuando controlas el paquete.
  3. Si tienes que enviar los dos formatos, mantén el estado global (singletons, registros, cachés) fuera del módulo que se carga por duplicado: sin estado compartido, el hazard deja de morder aunque haya dos copias.

El campo exports#

El campo exports en package.json es la forma moderna de declarar qué expone un paquete y cómo se carga. Reemplaza al campo main para la mayoría de los casos.

{
"name": "@team-builder/core",
"type": "module",
"exports": {
  ".": {
    "import": "./dist/index.mjs",
    "require": "./dist/index.cjs",
    "types": "./dist/index.d.ts"
  },
  "./heroes": {
    "import": "./dist/heroes.mjs",
    "require": "./dist/heroes.cjs",
    "types": "./dist/heroes.d.ts"
  }
}
}

Tres ideas aquí:

Conditional exports. Para cada subpath, declaras qué fichero se carga según el contexto: import para consumidores ESM, require para consumidores CJS, types para TypeScript. Los bundlers modernos (Vite, esbuild, Rollup) activan la condición import; Node en modo CJS activa require.

Subpaths públicos. El punto "." es el import raíz (import algo from "@team-builder/core"). El subpath "./heroes" es un import secundario (import { filtra } from "@team-builder/core/heroes"). Solo los subpaths declarados en exports son importables desde fuera.

Bloqueo de deep imports. Si alguien intenta hacer import { cosa } from "@team-builder/core/interno/utils" y ese subpath no está en exports, Node lanza un error de módulo no encontrado aunque el fichero exista en disco. Es una barrera de encapsulación real: el paquete controla su API pública y nadie puede colarse por rutas internas.

Antes del campo exports, main apuntaba al punto de entrada único y cualquier fichero del paquete era importable directamente con su ruta. Con exports, el paquete decide qué es público y qué es detalle interno.


El enlace vivo, en dos ficheros#

La diferencia entre snapshot y enlace vivo se ve mejor con dos módulos: uno exporta un binding mutable, y otro lo importa y lo lee antes y después de que el primero lo cambie.

// contador.ts — un módulo ESM con un binding exportado y una función que lo muta.
// En ESM lo que se exporta es un ENLACE VIVO al binding, no una copia.
export let n = 0;

// Incrementa el contador del módulo; quien importó `n` verá el nuevo valor.
export function incrementar() {
  n = n + 1;
}
// index.ts — importa el binding y lo lee antes y después de mutarlo.
import { n, incrementar } from "./contador";

// El valor inicial del binding.
console.log("n al importar: " + n);

// Mutamos el estado del módulo llamando a su función.
incrementar();
incrementar();

// El binding importado refleja el cambio EN VIVO (no era una copia de 0).
console.log("n tras incrementar dos veces: " + n);

Al ejecutar index.ts, la consola imprime dos líneas:

n al importar: 0
n tras incrementar dos veces: 2

incrementar() reasigna n dentro de contador.ts, y el import { n } de index.ts ve el nuevo valor: no era una copia del 0 inicial, sino un enlace vivo al binding del módulo. En CommonJS, const { n } = require("./contador") habría copiado el 0 en el momento del require y la segunda línea seguiría imprimiendo 0. Ese es el matiz que muerde cuando un módulo mantiene estado mutable.

Comprueba lo que sabes#

Pregunta 1 de 7

Evalúa el siguiente código y responde:

export let n = 0;
export function inc() { n = n + 1; }

// En otro fichero:
import { n, inc } from "./contador";
console.log(n);
inc();
inc();
console.log(n);
/* PREGUNTA: ¿Qué imprime este código? */

Tu turno#

Escribe en solucion.test.ts un test de Vitest que use el módulo equipo.ts que ya tienes dado: importa lo que necesites, ficha dos héroes y comprueba con expect que la alineación refleja los fichajes. Como alineacion es un enlace vivo, si tus imports son correctos el test verá los cambios que fichar() hace dentro del módulo y pasará en verde.

Ejercicio · en esta página

Imports correctos y enlaces vivos

Tienes un módulo equipo.ts que exporta un tipo (Rol), un binding vivo (alineacion), una función (fichar) y un export por defecto (el nombre del equipo). Escribe en solucion.test.ts un test de Vitest que importe lo necesario, fiche dos héroes y compruebe con expect que la alineación refleja los fichajes: si tus imports son correctos, el enlace vivo de ESM hará que el test pase.

Paso 1: Funciona

  • Importas el default (nombreEquipo) y los named (fichar, alineacion) de ./equipo
  • Fichas dos héroes y compruebas con expect que la alineación los contiene; el test pasa en verde
Ver soluciones
import { describe, it, expect } from "vitest";
// Importamos el nombre del equipo (export por defecto) y los named que necesitamos.
import nombreEquipo, { fichar, alineacion } from "./equipo";

describe("equipo", () => {
  it("la alineación contiene los héroes fichados", () => {
    // Fichamos dos héroes.
    fichar("Reinhardt");
    fichar("Ana");
    // La alineación importada refleja ambos fichajes.
    expect(alineacion).toEqual(["Reinhardt", "Ana"]);
    // El nombre del equipo viene del export por defecto del módulo.
    expect(nombreEquipo).toBe("Los Vengadores de Overwatch");
  });
});

Por qué este nivel

  • Importas el default y los named y asertas la alineación tras fichar: el test pasa y el ejercicio queda resuelto.
  • Es la corrección mínima: el enlace vivo de ESM hace que `alineacion` refleje los fichajes hechos dentro del módulo.
  • Su límite: no separa el tipo de los valores ni hace evidente el enlace vivo comprobando el estado inicial.