wandres.dev
EXPORTS PARA LIBRERÍAS · dual package

El dual package hazard: dos instancias

Por qué un paquete cargado a la vez como ESM y como CommonJS produce dos instancias con estado duplicado, singletons dobles e instanceof roto, y las estrategias de 2026 para evitarlo: ESM-only, un núcleo CJS compartido, la condition module-sync y require(esm).

⏱ 17 min

Hay un bug que no deja stack trace legible, no lanza excepción y hace dudar de tu propia cordura: un instanceof que devuelve falso cuando debería ser cierto, un contexto de React vacío sin razón, un singleton que de pronto tiene dos vidas. Casi siempre la causa es la misma y no está en tu lógica, sino en la resolución de módulos. Es el dual package hazard: el mismo paquete cargado dos veces, una por ESM y otra por CommonJS, generando dos identidades para lo que debería ser una. Entenderlo es dejar de perseguir fantasmas.

🎯 Al terminar esta lección sabrás
  • Ligar la identidad de un módulo a su ruta resuelta, no a su nombre.
  • Reconocer los síntomas: estado duplicado, singletons dobles, instanceof roto.
  • Explicar por qué el dual package es la causa raíz del problema.
  • Aplicar las estrategias de 2026 para evitarlo de raíz.

La identidad de un módulo es su ruta resuelta

La clave para entender todo el problema es una sola idea: un runtime cachea los módulos por su ruta resuelta, y esa ruta es su identidad. Dos especificadores que resuelven al mismo archivo comparten una única instancia evaluada; dos especificadores que resuelven a archivos distintos son, para el runtime, dos módulos diferentes con estado independiente, aunque su código fuente sea idéntico.

Aquí es donde el dual package tiende su trampa. Un paquete que publica un build ESM y otro CommonJS tiene dos archivos distintos para el mismo módulo lógico. Si en un mismo proceso una parte del grafo llega a él por import —cargando el .js ESM— y otra parte llega por require —cargando el .cjs—, el runtime evalúa los dos archivos y mantiene dos instancias separadas. No hay ningún mecanismo que las reconcilie: son, literalmente, dos módulos.

{
  "name": "@acme/store",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

Este exports es impecable —publint lo aprueba sin una queja— y sin embargo es la condición exacta del peligro. ./dist/index.js y ./dist/index.cjs son dos rutas, luego dos identidades. Basta con que la aplicación importe el paquete como ESM y una de sus dependencias lo requiera como CommonJS —algo que ocurre a diario en un grafo real y mixto— para que ambos archivos se evalúen y el estado se parta en dos. El autor no ve nada raro: el hazard no está en su código, está en la topología de quién lo carga y cómo, que él no controla.

flowchart TD
A[mismo paquete logico en el grafo] --> B[una rama llega por import]
A --> C[otra rama llega por require]
B --> D[instancia ESM con su estado]
C --> E[instancia CJS con su estado]
D --> F[dos singletons y instanceof roto]
E --> F
style D fill:#89b4fa,color:#11111b
style E fill:#fab387,color:#11111b
style F fill:#f38ba8,color:#11111b

Los síntomas: estado que se bifurca

El daño aparece siempre que el módulo mantiene algo con identidad o estado. Un singleton pensado para existir una vez existe dos veces, cada copia con su propio estado, y las escrituras de una no las ve la otra. Un caché se ignora porque cada instancia consulta el suyo. Una configuración global aparece vacía en un lado porque se inicializó en el otro.

El síntoma más desconcertante es el instanceof roto. Cuando una clase se define en ambas copias del módulo, un objeto creado por la copia ESM no es instancia de la clase de la copia CommonJS: son dos clases distintas que comparten nombre y código pero no identidad. Comparaciones de identidad que deberían ser triviales empiezan a fallar sin motivo aparente.

⚠️
instanceof que miente es la firma del problema

El dual package hazard rara vez da un error legible. Da algo peor: un instanceof que devuelve falso cuando debería ser cierto, un contexto de React que aparece vacío, un cliente de base de datos que abre el doble de conexiones, un event emitter cuyos listeners nunca se disparan porque quien emite y quien escucha usan instancias distintas. Cuando veas comparaciones de identidad que fallan entre paquetes, o estado global que no se comparte donde debería, sospecha de la resolución de módulos antes que de tu propia lógica. El bug no está donde lo estás buscando.

Un caso especialmente traicionero son las librerías con estado de contexto, como React o los clientes de bases de datos y de i18n. Si la aplicación carga una copia y una dependencia carga la otra, el proveedor y el consumidor viven en instancias distintas y la comunicación entre ellos, que depende de compartir una referencia global, simplemente no ocurre. El componente se renderiza, no lanza error, y el contexto llega vacío.

📝
El hazard es sobre todo un problema de runtime, no de bundle

Un matiz que ahorra horas de depuración: un bundler que empaqueta la aplicación entera suele resolver cada paquete una sola vez y elegir un único formato, de modo que el hazard no aparece en el bundle final. Donde muerde de verdad es en el runtime de Node sin empaquetar —un servidor, una herramienta de CLI, un test— donde import y require conviven y resuelven a archivos distintos. Por eso un mismo paquete puede funcionar perfecto en el build de producción y romperse en desarrollo o en el servidor: no es azar, es que solo una de las dos rutas atraviesa un bundler que deduplica.

Las estrategias de 2026 para evitarlo

La buena noticia es que en 2026 el problema tiene soluciones claras, y la mejor es no crearlo. La lista, de la más robusta a la más quirúrgica:

🎯

Publicar solo ESM

Sin build CommonJS no hay segundo archivo, y sin segundo archivo no hay dual package hazard. Con require(esm) estable, es la estrategia por defecto para librerías nuevas.

🧬

Núcleo CJS compartido

Si mantienes ambos formatos, extrae el estado a un módulo CommonJS fino que las dos caras reexportan. El estado vive una sola vez, en el núcleo compartido.

🔗

La condition module-sync

En Node moderno, module-sync sirve un ESM cargable de forma síncrona desde CommonJS, unificando ambas vías en un único archivo y evitando la bifurcación.

🧪

Auditar antes de publicar

publint y attw detectan configuraciones propensas al hazard antes de que un consumidor las sufra. Son parte del CI de cualquier librería seria.

Cuando el dual build es inevitable, la técnica del núcleo compartido aísla el riesgo. La idea es que el estado con identidad viva en un único módulo interno —normalmente CommonJS, para que ambas caras puedan cargarlo por igual— y que los dos builds públicos, el ESM y el CJS, no dupliquen ese estado sino que lo reexporten. El singleton existe una sola vez, en el núcleo, y las dos fachadas apuntan a él:

// core.cjs  →  el estado vive aqui, una sola vez
let instancia;
module.exports.getStore = () => (instancia ??= crearStore());

// index.js (ESM)  y  index.cjs (CJS) reexportan el mismo nucleo
export { getStore } from "./core.cjs";

Cuando sospechas que el hazard ya está ocurriendo, una sonda barata lo confirma: registra la instancia en el registro global de símbolos y avisa si alguien la puso antes que tú. Dos cargas del mismo módulo dispararán el aviso, porque el Symbol.for es compartido entre ambas copias aunque cada una tenga su propio estado:

const MARCA = Symbol.for("@acme/store/instancia");
if (globalThis[MARCA]) {
  console.warn("@acme/store cargado dos veces: probable dual package hazard");
}
globalThis[MARCA] = true;

La condition module-sync (Node 22 en adelante) ataca la raíz de otra forma. Permite declarar un ESM que Node puede cargar de forma síncrona incluso desde un require, de modo que el consumidor CommonJS y el consumidor ESM acaban en el mismo archivo en lugar de en dos:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "module-sync": "./dist/index.js",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

Con module-sync sirviendo el mismo archivo ESM a ambas vías, hay una sola instancia y el estado no se puede bifurcar. Combinada con require(esm), apunta al mismo destino que la estrategia ESM-only: un único artefacto, una única identidad. La require explícita queda solo como respaldo para runtimes que aún no reconozcan module-sync.

El hazard es el precio de duplicar la identidad de un módulo

Todo el dual package hazard se sigue de una sola verdad incómoda: cuando publicas dos formatos, publicas dos módulos, y ningún runtime puede saber que quieres que sean uno. La identidad de un módulo es su ruta resuelta, no tu intención de que sea único. Por eso el estado se bifurca, por eso el instanceof miente, por eso el contexto llega vacío: el runtime está haciendo exactamente lo correcto —cachear cada archivo por separado— y eres tú quien le pidió dos archivos para la misma cosa. Interiorizar esto cambia la forma de razonar sobre el bug. No lo depuras leyendo tu lógica, que casi siempre es correcta; lo depuras preguntando cuántas veces se cargó el módulo y por qué rutas. Y sobre todo, cambia la forma de diseñar la librería: si tu paquete tiene cualquier forma de estado compartido —un singleton, un caché, una clase que cruza fronteras con instanceof, un contexto—, el dual package deja de ser una comodidad y se vuelve un riesgo que hay que aislar deliberadamente. La estrategia más limpia en 2026 no es mitigar el hazard con un núcleo compartido, sino no invocarlo: publicar ESM-only y confiar en require(esm), que el ecosistema ya soporta. El autor experto no pregunta cómo convivir con dos instancias, sino cómo garantizar que solo haya una. Una identidad, un estado, cero fantasmas.

⚔️ Provoca y elimina el fantasma
  1. Publica un dual package con un singleton mutable y cárgalo por import y por require en el mismo proceso; confirma que el estado se bifurca.
  2. Define una clase exportada y verifica que un objeto de la copia ESM falla un instanceof contra la clase de la copia CommonJS.
  3. Extrae el estado a un núcleo CommonJS fino que ambas caras reexporten y comprueba que ahora la instancia es única.
  4. Reescribe el paquete como ESM-only y verifica que el hazard desaparece por completo al no existir el segundo archivo.
  5. Pasa attw sobre ambas versiones y observa qué avisos levanta sobre la configuración propensa al hazard.