Conditions: elegir la variante correcta
El mecanismo por el que un mismo import entrega ESM o CommonJS, código de navegador o de Node, tipos o runtime: las condiciones, su orden de prioridad y cómo las resuelve cada entorno.
Un paquete moderno no envía un solo módulo: envía muchas versiones del mismo. ESM para bundlers, CommonJS para Node heredado, declaraciones para TypeScript, un build alternativo para el navegador. ¿Cómo sabe un import cuál de todas cargar? Con las conditions: claves nombradas dentro de exports que el entorno resuelve según quién pregunta. Son el interruptor que hace que el mismo especificador entregue archivos distintos en Node, en el navegador y en el type-checker.
- Entender las conditions como resolución dependiente del entorno.
- Conocer el catálogo estándar: import, require, types, node, browser, default.
- Dominar la regla del orden: la primera coincidencia gana.
- Reconocer y evitar el dual package hazard.
Qué es una condition
En lugar de mapear un subpath a una ruta fija, lo mapeas a un objeto de condiciones. El entorno declara qué conditions tiene activas —import cuando la petición es ESM, require cuando es CommonJS— y Node recorre las claves del objeto en orden hasta la primera que reconoce.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
Un consumidor con import obtiene el .mjs; uno con require obtiene el .cjs; TypeScript, que activa la condition types, obtiene las declaraciones. Un solo especificador, tres archivos según el contexto.
El modelo mental correcto es una tabla de despacho. El paquete no sabe quién lo va a cargar; declara una respuesta para cada consumidor posible y delega la elección en el entorno. Ese desacoplamiento es lo que permite que una sola versión publicada sirva por igual a un bundler de navegador, a un proceso de Node y al type-checker, sin que el autor tenga que mantener paquetes separados para cada uno.
El catálogo estándar
import
La petición es ESM (import estático o dinámico). Sirve el build de módulos.
require
La petición es CommonJS (require). Sirve el build compatible con Node heredado.
types
TypeScript busca las declaraciones. Debe ir la primera para que el type-checker la vea antes que nada.
browser
Bundlers para navegador. Sustituye APIs de Node por equivalentes web.
node
Ejecución en Node, a menudo con acceso a los builtins node:. Complementa a import y require.
default
Comodín final: siempre coincide. Va siempre el último, como red de seguridad.
Existen más conditions: node-addons, development, production, worker, deno, electron, y la reciente module-sync (Node 22 en adelante) para ESM cargable de forma síncrona desde CommonJS. Los entornos pueden inventar conditions propias, y hay un registro comunitario de nombres para evitar colisiones entre herramientas.
Las conditions personalizadas se activan al arrancar y permiten servir variantes especiales, por ejemplo un mock en tests o el fuente sin compilar de un paquete del monorepo:
node --conditions=internal --conditions=test app.js
El paquete que quiera responder añade la clave correspondiente en su exports; si el entorno no la activa, esa rama sencillamente no se elige y se cae en la siguiente. Es la base del patrón de consumir el fuente TypeScript directamente que verás en la lección 5.
Si Node recorre el objeto entero y ninguna condition coincide, la resolución falla con ERR_PACKAGE_PATH_NOT_EXPORTED, igual que un subpath no listado. Por eso default es tan importante: garantiza que siempre exista una rama que responda pase lo que pase. Un objeto de conditions sin default es, en el fondo, una apuesta a que conoces de antemano todos los entornos posibles, y nunca los conoces todos.
Los runtimes que no son Node añaden además sus propias conditions: deno, bun, y workerd para el edge de Cloudflare. Un paquete que quiera rendir en el edge puede ofrecer una rama workerd con una implementación libre de APIs de Node. El registro comunitario de runtime-keys cataloga estos nombres precisamente para que dos herramientas no elijan el mismo con significados distintos, un riesgo real cuando cada plataforma inventa el suyo.
El orden lo es todo
Esta es la regla que más bugs silenciosos causa en todo el empaquetado. A diferencia de CSS o de los patrones de subpath, donde gana lo más específico, aquí gana la PRIMERA clave que el entorno reconozca, leída de arriba abajo dentro del objeto. Por eso types va siempre primera: TypeScript debe encontrar las declaraciones antes de caer en import o require, que le entregarían JavaScript sin tipos. Y por eso default va siempre la última: coincide con todo, así que cualquier condition escrita debajo de ella es código muerto, inalcanzable. Un objeto mal ordenado —con default arriba y import abajo— entrega el archivo equivocado sin lanzar ningún error: compila, se instala, y simplemente carga el módulo que no era. Es el fallo de empaquetado más difícil de detectar a ojo. Por eso en 2026 herramientas como publint y arethetypeswrong auditan este orden automáticamente antes de publicar. Interiorizar “orden igual a prioridad” es la diferencia entre un paquete que funciona en los cinco runtimes y uno que falla de forma misteriosa en exactamente uno.
Las conditions anidan. Un entorno puede satisfacer varias a la vez —navegador y ESM— y Node desciende por la intersección hasta la ruta final:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"browser": {
"import": "./dist/browser.mjs",
"require": "./dist/browser.cjs"
},
"node": {
"import": "./dist/node.mjs",
"require": "./dist/node.cjs"
},
"default": "./dist/index.mjs"
}
}
}
Un bundler de navegador que pide ESM entra por browser, luego por import, y obtiene browser.mjs. Node con require entra por node, luego por require, y obtiene node.cjs.
Las conditions development y production merecen mención aparte: los bundlers las activan según el modo del build, y muchas librerías las usan para enviar avisos y comprobaciones caras solo en desarrollo, dejando el build de producción limpio y ligero. Es el mismo mecanismo de resolución puesto al servicio del rendimiento en runtime, no solo de la compatibilidad entre formatos.
Visto como flujo, el entorno propone su conjunto de conditions y el paquete responde con la primera que reconoce, descendiendo por los objetos anidados hasta una ruta:
flowchart LR A[el entorno activa un conjunto de conditions] --> B[leer las claves de arriba abajo] B --> C[primera clave reconocida gana] C --> D[si el valor es un objeto anidar y repetir] D --> E[ruta final del archivo]
La condition types la consulta únicamente TypeScript, y solo cuando moduleResolution vale node16, nodenext o bundler. Con el viejo node clásico, TypeScript ignora exports por completo y cae en la resolución heredada. Si tus tipos no se encuentran pese a un exports impecable, revisa esa opción del tsconfig antes que ninguna otra cosa: es la causa número uno de tipos que no aparecen.
El peligro del paquete dual
Enviar ESM y CommonJS a la vez abre el llamado dual package hazard: si un mismo módulo se carga por las dos vías dentro del mismo proceso, existen dos instancias con estado duplicado. Dos “singletons”, dos cachés, y un instanceof que falla porque la clase cargada por ESM no es la misma que cargó CommonJS. Las estrategias de 2026 son claras: publicar solo ESM y confiar en require(ESM), ya estable en Node 24; mantener el estado en un módulo CommonJS interno compartido por ambos builds; o apoyarse en la condition module-sync. La tendencia del ecosistema es inequívoca: ESM-only.
Conviene saber que un paquete puede ser deliberadamente import-only, con solo la rama import y sin require, o al revés. Un paquete import-only obliga a sus consumidores a ser ESM, y es hoy una forma legítima y creciente de empujar la migración del ecosistema. La ausencia de una condition es, en sí misma, una decisión de compatibilidad tan expresiva como su presencia.
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 singleton que de pronto tiene dos vidas. La causa es siempre la misma —el mismo módulo cargado dos veces, una por ESM y otra por CommonJS, generando dos identidades distintas para la misma clase. Cuando veas comparaciones de identidad que fallan sin motivo aparente entre paquetes, sospecha de la resolución antes que de tu propia lógica.
- Escribe un
exportscontypes,import,requireydefaulten el orden correcto y verifica cada rama. - Invierte el orden poniendo
defaultla primera y observa qué se carga (spoiler: siempre lo mismo, y sin error). - Ejecuta
publintsobre un paquete tuyo y corrige todo lo que marque sobre conditions y orden. - Fuerza una condition propia con
node --conditions=testy añade una rama"test"a tuexportspara servir un mock.