La condition types y el orden que importa
Por qué la condition types debe ir primera y el orden de las conditions decide qué archivo se carga: la resolución por primera coincidencia, las conditions anidadas, los tipos por formato con d.mts y d.cts, y el papel de moduleResolution en TypeScript.
Hay una regla en el campo exports que causa más bugs silenciosos que ninguna otra, y no se parece a nada de lo que trae la intuición. Las conditions no se resuelven por especificidad, como CSS, sino por orden de escritura: gana la primera clave que el entorno reconoce, leída de arriba abajo. De esa regla se deriva todo —por qué types va siempre primera, por qué default va siempre última, y por qué un objeto mal ordenado entrega el archivo equivocado sin lanzar un solo error—. Es la parte del empaquetado donde el orden es semántica.
- Interiorizar la resolución de conditions por primera coincidencia, no por especificidad.
- Colocar
typesla primera ydefaultla última por razones concretas. - Anidar conditions para servir tipos distintos a ESM y a CommonJS.
- Alinear
moduleResolutionde TypeScript con el resolver real del build.
Primera coincidencia gana: la regla contraintuitiva
Cuando el entorno resuelve un objeto de conditions, recorre sus claves de arriba abajo y se queda con la primera que reconoce. No busca la más específica ni la más adecuada: la primera. Esa única regla gobierna todo el comportamiento del bloque, y contradice la intuición que traemos de casi cualquier otro sistema de configuración.
De ahí se siguen dos consecuencias que hay que grabar. types va siempre primera, porque TypeScript debe encontrar las declaraciones antes de caer en import o require, que le entregarían JavaScript sin tipos. Y default va siempre última, porque coincide con todo: cualquier condition escrita debajo de ella es código muerto, inalcanzable para siempre.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}
Lo insidioso es que un orden incorrecto no lanza ningún error. Un objeto con default arriba e import abajo compila, se instala y simplemente carga el archivo que no era. No hay excepción, no hay aviso: solo un comportamiento equivocado que quizá descubras semanas después, cuando un consumidor reporte que recibe el build de navegador en Node. Es el fallo de empaquetado más difícil de ver a ojo, y la razón de que existan herramientas que lo auditan automáticamente.
El orden canónico de las conditions más comunes, de la más específica a la red de seguridad, es una plantilla que conviene memorizar:
| Posición | Condition | Quién la activa |
|---|---|---|
| Primera | types |
Solo el type-checker de TypeScript |
| Segunda | browser |
Bundlers para el navegador |
| Tercera | node |
Ejecución en Node |
| Anidadas | import / require |
ESM frente a CommonJS |
| Última | default |
Comodín universal, siempre coincide |
La regla operativa que se deriva es simple: el lector más exigente y específico va arriba, las variantes de formato en medio, y el comodín que coincide con todo al final. Cualquier clave escrita por debajo de default es inalcanzable por definición.
Ese default final no es solo una convención: es una garantía de que la resolución nunca se queda sin respuesta. Si el entorno recorre el objeto entero y ninguna condition coincide, la resolución falla con ERR_PACKAGE_PATH_NOT_EXPORTED, igual que un subpath no listado. Omitir default es, en el fondo, apostar a que conoces de antemano todos los entornos posibles que cargarán tu paquete, y nunca los conoces todos: un runtime nuevo, una condition que no previste, y tu paquete deja de resolver sin que tú hayas tocado nada. La red de seguridad universal al final del objeto es lo que hace que esa apuesta no exista.
flowchart TD A[TypeScript pide el tipo de mi-lib] --> B[recorrer exports de arriba abajo] B -->|types va primera| C[encuentra las declaraciones d ts] B -->|import va antes que types| D[cae en JavaScript sin tipos] C --> E[type checking correcto] D --> F[tipos perdidos e implicit any] style C fill:#a6e3a1,color:#11111b style D fill:#f38ba8,color:#11111b style F fill:#f38ba8,color:#11111b
Para fijar la intuición, este es el mismo objeto escrito mal: default arriba del todo. No lanza ningún error, pero convierte todo lo que hay debajo en código muerto:
{
"exports": {
".": {
"default": "./dist/index.js",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
default coincide con cualquier entorno, así que la resolución se detiene en la primera línea y jamás llega a import ni a require: ambos son inalcanzables. El paquete compila, se instala y sirve siempre index.js, sin importar cómo se le pida. Es exactamente el tipo de fallo que ningún test detecta y que solo una auditoría específica del orden puede cazar.
La condition types y sus condiciones de activación
La condition types la consulta únicamente TypeScript, y solo bajo ciertas configuraciones. Si tus tipos no aparecen pese a un exports impecable, la causa número uno está en el tsconfig: la condition types solo se lee 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, buscando un index.d.ts a la vieja usanza.
Esto tiene un corolario para el autor: publicar un exports moderno no basta si tus consumidores no han actualizado su moduleResolution. Por eso durante la transición se mantiene el campo types en la raíz del package.json como respaldo, para las herramientas y proyectos que aún no leen la condition. Es la misma lógica de red de compatibilidad que rige a main frente a exports.
Antes de que la condition types soportara subpaths, la única forma de mapear declaraciones por ruta era el campo typesVersions, una construcción verbosa y frágil. En 2026, con moduleResolution en nodenext o bundler, la condition types dentro de cada subpath de exports cubre el caso por completo. Si arrancas una librería nueva, no toques typesVersions: resuelve los tipos con types y mantén una sola fuente de verdad para cada ruta.
Conditions anidadas y tipos por formato
Las conditions anidan. Un objeto de conditions puede tener como valor otro objeto de conditions, y el entorno desciende por la intersección de lo que reconoce hasta llegar a una ruta final. Esto permite algo que un dual package correcto casi siempre necesita: servir declaraciones de tipos distintas a ESM y a CommonJS.
La razón es sutil pero real. Un .d.ts describe una forma de módulo, y esa forma no es idéntica en ESM y en CommonJS —la interoperabilidad del default, la presencia o no de module.exports, el modo en que se resuelven los named exports difieren—. Un mismo archivo de declaraciones para ambos formatos puede mentirle al type-checker en uno de los dos. La solución precisa es anidar types dentro de cada rama de formato, con las extensiones .d.mts para ESM y .d.cts para CommonJS:
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
Observa que dentro de cada rama, types vuelve a ir primera y default última: la regla del orden se aplica en cada nivel de anidamiento, no solo en el de arriba. Este patrón —a veces llamado tipos por condition— es lo que evita que un consumidor CommonJS reciba unas declaraciones pensadas para el default interop de ESM, un desajuste que produce errores de tipos que no se corresponden con ningún error de runtime.
El anidamiento no se limita a formatos estándar: un autor puede definir conditions propias que el entorno activa al arrancar, y colocarlas antes de las genéricas para servir una variante especial. El caso canónico es exponer el fuente TypeScript sin compilar a los paquetes internos de un monorepo, bajo una condition que solo el propio repo activa:
node --conditions=source app.js
Con una rama "source": "./src/index.ts" escrita antes de import y require, el monorepo consume el fuente directamente mientras el mundo exterior, que no activa esa condition, cae en los builds compilados. El registro comunitario de runtime-keys cataloga estos nombres —deno, bun, workerd y demás— precisamente para que dos herramientas no elijan el mismo con significados distintos. Una condition personalizada es, en el fondo, una rama más de la gramática: se ordena por prioridad como cualquier otra, y su ausencia en un entorno la vuelve simplemente inalcanzable.
Si tu proyecto usa un bundler, pon "moduleResolution": "bundler" en el tsconfig. 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, o al revés.
El error de fondo es leer un objeto de conditions como una lista de opciones equivalentes entre las que el entorno elige la mejor. No lo es. Es una gramática ordenada donde la posición codifica prioridad, y el entorno no elige: toma la primera regla que puede aplicar y se detiene. Interiorizar ese modelo reordena toda tu comprensión del empaquetado. types va primera no por convención estética, sino porque es la única condition que consume un actor —el type-checker— que necesita ver las declaraciones antes que cualquier JavaScript; ponerla después de import la vuelve inalcanzable para su único lector. default va última no por cortesía, sino porque su semántica es coincidir siempre, y todo lo que escribas debajo de un comodín universal es, por definición, código muerto. Las conditions anidan porque un entorno satisface varias propiedades a la vez —es ESM y es navegador y quiere tipos— y la resolución debe descender por esa intersección en un orden definido. Cuando ves el bloque exports como una gramática ordenada en lugar de un diccionario desordenado, dejas de colocar las claves por intuición y empiezas a colocarlas por su función: primero el lector más exigente y específico, después las variantes de formato, y al final la red de seguridad universal. Esa disciplina es la diferencia entre un paquete que resuelve correctamente en los cinco runtimes y uno que falla de forma misteriosa en exactamente uno, sin lanzar jamás un error que te diga dónde mirar.
- Escribe un
exportscontypes,import,requireydefaulten el orden correcto y verifica cada rama. - Invierte el orden poniendo
defaultla primera y observa que se carga siempre lo mismo, sin ningún error. - Anida
typesdentro deimporty derequirecon.d.mtsy.d.cts, y comprueba que cada formato recibe sus declaraciones. - Cambia
moduleResolutiondenodeanodenexty confirma que solo entonces TypeScript lee la conditiontypes. - Rompe el orden a propósito y ejecuta
publintpara ver cómo detecta el desajuste sin que el paquete lance ninguna excepción.