El campo exports: la frontera del paquete
Cómo el campo exports sella un paquete: define los puntos de entrada públicos, habilita subpaths controlados con patrones y bloquea todo acceso a los archivos internos.
Durante años un paquete de npm fue una carpeta abierta: cualquiera podía importar paquete/dist/internal/secreto.js y quedar atado a tus tripas. El campo exports cambió esa realidad. Convierte el package.json de un simple mapa de entrada en una frontera real: el autor declara exactamente qué es público y el runtime hace cumplir esa frontera. Es la pieza que convierte un directorio de archivos en un módulo de verdad.
- Entender
exportscomo encapsulación, no como un alias más. - Definir el punto de entrada principal y subpaths con patrones comodín.
- Bloquear los deep imports a los archivos internos del paquete.
- Combinar la forma azucarada, los patrones y el orden correcto sin romper nada.
De main a exports
El viejo campo main era una sola puerta y no impedía entrar por las ventanas: apuntaba al punto de entrada, pero nada frenaba un import a cualquier archivo del paquete. El campo exports (Node 12.7 en adelante, universal en 2026) redefine el paquete entero. En su forma más simple es azúcar para un único punto de entrada:
{
"name": "mi-lib",
"exports": "./dist/index.js"
}
La forma completa es un objeto de subpaths. La clave "." representa el entry principal, y es habitual re-exponer el propio package.json porque muchas herramientas lo leen en tiempo de ejecución:
{
"exports": {
".": "./dist/index.js",
"./package.json": "./package.json"
}
}
Conviene entender que exports no borra a main: lo eclipsa. Un runtime que entiende exports lo usa y descarta main; una herramienta antigua que lo ignora cae en main como respaldo. Por eso muchos paquetes conservan main, module y types en la raíz junto a exports: son la red para consumidores que aún no implementan el estándar. La regla mental es que exports es la verdad para lo moderno y los campos sueltos son pura compatibilidad hacia atrás.
Hay un detalle operativo fácil de olvidar: exports apunta a archivos que deben existir en el paquete publicado, no en tu repositorio. Si el campo files o tu .npmignore excluye la carpeta dist, acabarás con un exports que apunta al vacío y un paquete que no resuelve nada tras instalarse. La ruta mapeada y la ruta empaquetada tienen que coincidir, y comprobarlo es trivial: empaqueta en seco y mira lo que sale.
Subpaths y patrones
Puedes publicar varios puntos de entrada nombrados y, para no listar archivo por archivo, usar un patrón comodín. El * no es un glob: es un marcador posicional que se sustituye literalmente por lo que el consumidor escriba.
{
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils/index.js",
"./features/*": "./dist/features/*.js"
}
}
Con ese patrón, mi-lib/features/auth resuelve a ./dist/features/auth.js sin que tengas que enumerar cada feature. Expones una carpeta completa de forma controlada y el resto del paquete permanece sellado. Cuando dos entradas encajan, gana siempre la más específica: una clave literal como ./utils tiene prioridad sobre un patrón que también la cubriría.
El comodín funciona en los dos lados de la correspondencia y debe cuadrar: lo que captura el * de la clave se sustituye literalmente en el * del valor. Esta misma mecánica gobierna la resolución de tipos cuando el patrón se combina con la condition types, de modo que un único patrón puede servir a la vez el JavaScript y sus declaraciones para toda una carpeta, sin duplicar entradas ni mantener dos mapas paralelos.
El árbol de decisiones completo, desde el especificador hasta el archivo o el error, es este:
flowchart TD A[import de mi-lib mas un subpath] --> B[leer el campo exports] B -->|subpath listado| C[servir la ruta mapeada] B -->|coincide un patron| D[sustituir el comodin y servir] B -->|mapeado a null| E[acceso bloqueado] B -->|no aparece| F[error PACKAGE PATH NOT EXPORTED]
En 2026, con la mayoría de paquetes ya solo en ESM, la forma más común es la mínima: ".": { "types": "./d.ts", "default": "./index.js" }. Con default como única rama de runtime cubres cualquier entorno que no necesite diferenciar ESM de CommonJS, y types sirve las declaraciones. Menos ramas significa menos maneras de equivocarse en el orden y menos superficie que auditar antes de publicar.
Encapsulación: lo que exports prohíbe
Aquí está la revolución, y no es mapear: es prohibir. En cuanto un paquete define exports, todo lo que no aparece listado se vuelve inaccesible. Un import "mi-lib/dist/internal/x.js" deja de funcionar y lanza ERR_PACKAGE_PATH_NOT_EXPORTED, aunque el archivo exista en disco. Puedes además vetar rutas de forma explícita mapeándolas a null:
{
"exports": {
".": "./dist/index.js",
"./features/*": "./dist/features/*.js",
"./features/internal/*": null
}
}
El patrón más específico gana también aquí: features/internal queda bloqueado pese a caer bajo features/*. Así abres una carpeta y, dentro de ella, cierras una subcarpeta.
El bloqueo tiene además una dimensión de seguridad que suele pasarse por alto. Si nadie puede importar tu-dep/dist/internal/parser.js, nadie puede acoplarse a sus detalles ni parchearlos desde fuera, lo que reduce la superficie de la cadena de suministro y hace que refactorizar el interior sea seguro. La encapsulación no es solo higiene de API: es también una línea de defensa contra el acoplamiento no deseado a las tripas de un paquete.
Un exports con "./*": "./dist/*" parece cómodo, pero anula el propósito del campo: vuelve a exponer el paquete entero y devuelves la puerta abierta que exports vino a cerrar. Si mapeas con comodín, hazlo sobre una carpeta concreta como ./features/*, nunca sobre la raíz del dist. La superficie pública debe ser lo mínimo que tus usuarios necesitan, no todo lo que da la casualidad de que compilaste al lado.
Antes de exports, la superficie pública de un paquete era accidental: consistía en cualquier ruta que la gente hubiera decidido importar, la pensaras pública o no. Cambiar un archivo interno rompía a usuarios que jamás debiste tener. Con exports, la superficie pasa a ser un contrato deliberado y verificado por el runtime. Eso habilita tres cosas que valen oro. Primero, refactors seguros: puedes mover o renombrar todo lo que hay bajo dist/internal sin romper a nadie, porque el runtime garantiza que nadie podía importarlo. Segundo, un versionado semántico honesto: sabes con precisión qué es un cambio mayor porque sabes con precisión qué es público. Tercero, un tree-shaking más agresivo, porque el grafo de entradas es finito y conocido. Es el mismo principio que static en C o private en una clase, elevado a la escala del paquete. En 2026 ya no es cosa solo de Node: Vite/Rolldown, webpack y esbuild respetan exports, de modo que la frontera que dibujas se hace cumplir en toda la cadena, del runtime al bundler.
Cada subpath de exports es también una frontera para el tree-shaking y para las herramientas de análisis: al declarar puntos de entrada finitos y conocidos, el bundler sabe exactamente qué puede eliminar, y los reportes de tamaño pueden atribuir peso por entrada. Un paquete con exports bien granulado no solo es más seguro de mantener, sino más fácil de optimizar para quien lo consume.
Errores frecuentes
Olvidar el package.json
Muchas herramientas leen paquete/package.json en runtime. Si tienes exports, re-exponlo explícitamente o esas herramientas fallarán.
Romper deep imports
Publicar exports sella el paquete y rompe a quien importaba rutas internas. Es un cambio mayor: anúncialo en el changelog.
Orden con conditions
Cuando un subpath usa conditions, el orden importa: types primero, default al final. Lo verás en la lección siguiente.
Self-reference
Dentro del propio paquete puedes importar por tu nombre (mi-lib/utils) y respeta exports, útil para probar la frontera que publicas.
El coste de equivocarse en exports es un paquete roto para miles de personas, y los errores son sutiles: una ruta que no existe, un subpath olvidado, un tipo que no encaja con su runtime. En 2026 el estándar de facto antes de publicar es pasar publint, que valida la coherencia del mapa, e instalar el paquete de verdad en un proyecto limpio para importarlo como lo haría un usuario. Publicar es difícil de deshacer; verificar la frontera cuesta minutos y ahorra un parche de emergencia.
- Convierte un paquete de
mainaexportscon la clave"."y al menos un subpath nombrado. - Añade un patrón
./features/*y verifica quemi-lib/features/authresuelve al archivo correcto. - Bloquea
./features/internal/*connully confirma que un import a esa ruta lanzaERR_PACKAGE_PATH_NOT_EXPORTED. - Importa desde el propio paquete usando su nombre (self-reference) y comprueba que pasa por
exportsigual que un consumidor externo.