wandres.dev
EXPORTS PARA LIBRERÍAS · dual package

Dual package: ofrecer ESM y CommonJS

Cómo una librería sirve ESM y CommonJS desde un solo paquete con las conditions import y require: extensiones frente al campo type, la generación del doble build con tsdown o unbuild, y por qué require(esm) empuja el ecosistema hacia ESM-only en 2026.

⏱ 17 min

El ecosistema de JavaScript arrastra dos sistemas de módulos que no se hablan del todo: ESM, moderno y estático, y CommonJS, veterano y dinámico. Durante la transición, una librería seria tenía que hablar los dos idiomas para no dejar a nadie fuera. El dual package es ese paquete bilingüe: un solo artefacto que entrega ESM a quien hace import y CommonJS a quien hace require. Entender cómo se construye —y por qué en 2026 cada vez menos librerías se molestan— es clave para publicar sin fricción.

🎯 Al terminar esta lección sabrás
  • Servir dos formatos desde un paquete con las conditions import y require.
  • Distinguir el papel de las extensiones .mjs y .cjs frente al campo type.
  • Generar el doble build con un empaquetador de librerías, no a mano.
  • Situar por qué require(esm) hace opcional el dual package en 2026.

Un paquete, dos idiomas

La pieza que hace posible el dual package son las conditions dentro de exports. En lugar de mapear la entrada a una ruta fija, la mapeas a un objeto donde import apunta al build ESM y require al build CommonJS. El entorno declara qué condition tiene activa —import cuando la petición es ESM, require cuando es CommonJS— y Node sirve la rama correspondiente:

{
  "name": "@acme/core",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

Un consumidor con import obtiene el módulo ESM; uno con require obtiene el CommonJS; el type-checker, que activa types, obtiene las declaraciones. Un solo especificador publicado, tres respuestas según quién pregunta. El modelo mental correcto es una tabla de despacho: el paquete no sabe quién lo cargará, declara una respuesta para cada consumidor posible y delega la elección en el entorno.

Conviene subrayar que ambas ramas deben ofrecer la misma superficie de API para que la promesa sea honesta. Si tu build ESM exporta parse y stringify como named exports, tu build CommonJS tiene que exponer exactamente lo mismo en su module.exports; de lo contrario, un consumidor que hace require recibirá una forma distinta de la que documenta tu README pensado para import. Esa paridad no se mantiene sola cuando escribes los dos formatos a mano, y es una de las razones por las que la generación automática del doble build no es un lujo sino una necesidad.

ℹ️
El campo browser y el edge también caben aquí

El mismo mecanismo sirve para más de dos mundos. Una condition browser entrega un build sin APIs de Node para los bundlers de cliente, y runtimes como workerd, deno o bun añaden sus propias conditions para el edge. El dual package ESM/CJS es solo el caso más común de un patrón más general: una matriz de builds servida por resolución condicional desde un único paquete.

Extensiones y el campo type: el detalle que rompe builds

Aquí está la trampa que más paquetes duales rompe. Node decide si un archivo .js es ESM o CommonJS mirando el campo type del package.json más cercano: con "type": "module", cada .js es ESM; sin él, es CommonJS. Las extensiones explícitas ganan siempre a ese campo: un .mjs es ESM pase lo que pase, y un .cjs es CommonJS pase lo que pase.

El error clásico es publicar un build CommonJS con extensión .js dentro de un paquete que declara "type": "module". Node lo trata como ESM, encuentra un require o un module.exports dentro, y lanza un error de sintaxis que desconcierta porque el archivo “parece correcto”. La regla segura para un dual package es no dejar la interpretación al azar: usa extensiones explícitas para el formato que no coincide con tu type.

{
  "name": "@acme/core",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

Con "type": "module", el index.js es ESM y el CommonJS viaja como index.cjs explícito. Los campos main y module en la raíz son la red de compatibilidad para herramientas que ignoran exports. La correspondencia entre formato, extensión y condition tiene que ser perfecta; un solo desajuste y una mitad del paquete falla en silencio para la mitad de los consumidores.

flowchart LR
A[una sola base de codigo TypeScript] --> B[empaquetador de librerias]
B --> C[build ESM index js]
B --> D[build CommonJS index cjs]
B --> E[declaraciones index d ts]
C --> F[condition import]
D --> G[condition require]
E --> H[condition types]
style B fill:#cba6f7,color:#11111b
style F fill:#a6e3a1,color:#11111b
style G fill:#fab387,color:#11111b

No lo construyas a mano

Generar dos formatos, sus mapas de sourcemaps y sus declaraciones de tipos coherentes con el exports es tedioso y una fuente inagotable de errores sutiles. En 2026 nadie serio escribe el doble build a mano: se delega en un empaquetador de librerías. tsdown —construido sobre Rolldown y Oxc— es la opción de referencia por velocidad; unbuild y tsup siguen muy presentes. Todos producen las variantes ESM y CJS, los .d.ts, y muchos generan o validan el bloque exports a partir de una sola configuración.

# genera dist/index.js (ESM), dist/index.cjs (CJS) y los .d.ts
npx tsdown src/index.ts --format esm,cjs --dts

El criterio de elección en 2026, resumido:

Herramienta Motor Rasgo distintivo
tsdown Rolldown y Oxc El más rápido; genera y valida exports
unbuild Rollup y mkdist Estándar en el ecosistema de UnJS
tsup esbuild Maduro y ubicuo, hoy en modo mantenimiento
vite build --lib Rollup/Rolldown Reutiliza la config de Vite del proyecto

La convergencia de fondo es clara: todos parten de una sola fuente TypeScript y emiten la matriz de artefactos, de modo que la coherencia entre formato, extensión y condition deja de depender de tu disciplina y pasa a ser responsabilidad de la herramienta. Escribir dos builds a mano no solo es tedioso: reintroduce justo la clase de desajuste que estas herramientas eliminan por construcción.

⚠️
El estado compartido es el punto débil del dual build

Duplicar el formato duplica también, potencialmente, el estado. Si tu librería mantiene un singleton, un caché o un registro global, y el build ESM y el CJS se cargan a la vez en el mismo proceso, tendrás dos copias con estados separados. Es el dual package hazard, y es la razón principal por la que ofrecer ambos formatos no es gratis. La lección siguiente lo diseca entero; por ahora, recuerda que un dual package con estado compartido exige cuidado extra.

Por qué 2026 empuja a ESM-only

El dual package existió para resolver un problema concreto: durante años, un módulo CommonJS no podía require() un módulo ESM —lanzaba ERR_REQUIRE_ESM— porque require es síncrono y la evaluación de ESM podía ser asíncrona. Publicar solo ESM condenaba a tus consumidores CommonJS a no poder cargarte de forma síncrona. Ofrecer ambos formatos era la única forma de no dejar a nadie fuera.

Ese cimiento se movió. Node estabilizó require(esm): permite requerir de forma síncrona módulos ESM que no usen top-level await. De golpe, la mayoría de consumidores CommonJS pueden cargar un paquete ESM-only sin que su autor tenga que publicar una segunda copia. La razón principal para mantener un dual build se desvaneció, y con ella su coste: menos artefactos, menos superficie que auditar, cero riesgo de dual package hazard.

Queda un matiz honesto: require(esm) falla si el módulo ESM usa top-level await, porque no se puede esperar de forma asíncrona dentro de una llamada síncrona. Una librería ESM-only que quiera ser requerible desde CommonJS debe, por tanto, evitar el top-level await en su punto de entrada, o aceptar que sus consumidores CommonJS la carguen con import() dinámico. Es una restricción menor y bien delimitada, muy lejos del muro que suponía el viejo ERR_REQUIRE_ESM, y no cambia la conclusión: para una librería nueva, ESM-only es el punto de partida sensato en 2026.

La forma que adopta ese punto de partida es deliberadamente mínima: un solo formato, un exports con dos ramas, y nada de extensiones especiales porque "type": "module" ya hace ESM cada .js:

{
  "name": "@acme/core",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  }
}

Compara esa superficie con la del dual package: la mitad de artefactos, la mitad de conditions, ninguna posibilidad de que ESM y CJS se desincronicen y cero riesgo de duplicar el estado. Menos ramas significa menos maneras de equivocarse en el orden y menos que auditar antes de publicar. Esa economía es, en sí misma, un argumento de diseño.

El dual package fue un puente, no un destino

Conviene ver el dual package por lo que es: una tecnología de transición, un puente tendido sobre el abismo entre dos sistemas de módulos mientras el ecosistema cruzaba. Como todo puente, tiene un coste de mantenimiento —dos builds, dos superficies, el riesgo permanente de que el estado se duplique— que solo se justifica mientras haga falta cruzar. En 2026 la orilla de destino ya está urbanizada: require(esm) es estable en Node, los bundlers hablan ESM de forma nativa desde hace años, y las librerías nuevas nacen directamente ESM-only sin que nadie se queje. La decisión del autor moderno no es “cómo construyo el mejor dual package”, sino “necesito de verdad el dual package”. Para una librería nueva, la respuesta casi siempre es no: publica ESM-only, gana simplicidad, elimina de raíz una clase entera de bugs de identidad de módulo, y empuja al ecosistema en la dirección correcta. El dual package sigue siendo la herramienta adecuada para una librería madura con una base enorme de consumidores CommonJS antiguos que aún no pueden actualizar Node, pero es una deuda que se paga, no una virtud que se exhibe. Saber cuándo tender el puente y cuándo confiar en que la otra orilla ya es tierra firme es lo que distingue al autor que entiende el momento del ecosistema del que copia una plantilla de hace cinco años.

⚔️ Construye y cuestiona el dual build
  1. Genera un dual build con tsdown --format esm,cjs --dts y examina las extensiones de los artefactos frente al campo type.
  2. Escribe el exports con types, import y require, e importa el paquete desde un archivo .mjs y desde uno .cjs para ver cada rama.
  3. Provoca el error de extensión: publica un CommonJS como .js bajo "type": "module" y observa el fallo de sintaxis.
  4. Reescribe el mismo paquete como ESM-only y cárgalo con require() desde CommonJS gracias a require(esm).
  5. Compara la superficie de ambas versiones y decide, con criterio, si tu librería necesita de verdad el dual package.