wandres.dev
EXPORTS PARA LIBRERÍAS · dual package

Diseñar la superficie: entrada y subpaths

Cómo un autor de librerías diseña el campo exports como contrato: el punto de entrada principal, los subpaths nombrados como ./utils, los patrones de carpeta y el sellado de todo lo interno para poder refactorizar sin romper semver.

⏱ 17 min

Publicar una librería en 2026 no es subir una carpeta: es firmar un contrato. El campo exports es el instrumento donde ese contrato queda escrito. Como autor decides, ruta por ruta, qué es superficie pública —lo que prometes mantener— y qué es interior privado —lo que te reservas el derecho de reescribir mañana—. Dominar exports desde el lado de quien publica separa la librería que puedes evolucionar con confianza de la jaula que tus propios usuarios construyen importando tus tripas.

🎯 Al terminar esta lección sabrás
  • Pensar exports como el contrato público de tu librería, no como un alias.
  • Declarar la entrada principal "." y subpaths nombrados como ./utils.
  • Exponer carpetas enteras con patrones sin listar archivo por archivo.
  • Sellar lo interno para poder refactorizar sin incumplir semver.

El paquete como contrato, no como carpeta

Antes de exports, la superficie pública de una librería no la decidía su autor: la decidían sus usuarios. El campo main señalaba una única puerta, pero nada impedía que alguien importara mi-lib/dist/internal/parser.js y quedara acoplado a un archivo que tú considerabas privado. Esa es la ley de Hyrum llevada al empaquetado: con suficientes consumidores, cada comportamiento observable de tu paquete —cada ruta que resuelve— se convierte en una dependencia de alguien, la hubieras diseñado o no.

El campo exports invierte esa dinámica. En cuanto lo declaras, el paquete pasa de ser una carpeta abierta a ser un módulo sellado: solo las rutas que enumeras son alcanzables desde fuera, y todo lo demás lanza ERR_PACKAGE_PATH_NOT_EXPORTED aunque el archivo exista en disco. La superficie deja de ser accidental y pasa a ser un acto de diseño deliberado, verificado por el runtime en cada resolución.

Para un autor, ese cambio tiene una consecuencia enorme: puedes evolucionar. Todo lo que no está en exports es tuyo para renombrar, mover o borrar sin que sea un cambio mayor, porque el runtime garantiza que nadie podía depender de ello. El contrato que firmas es exactamente el conjunto de claves de tu exports, ni una ruta más.

ℹ️
exports eclipsa a main, no lo borra

Un runtime que entiende exports lo usa y descarta main; una herramienta antigua que lo ignora cae en main como respaldo. Por eso muchas librerías conservan main 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.

La entrada principal y los subpaths nombrados

La forma mínima de exports es azúcar para un único punto de entrada. La forma completa es un objeto de subpaths donde la clave "." representa la entrada principal, la que resuelve al importar el paquete por su nombre desnudo. Es habitual re-exponer el propio package.json, porque muchas herramientas lo leen en tiempo de ejecución y sin esa línea también quedaría sellado:

{
  "name": "@acme/ui",
  "exports": {
    ".": "./dist/index.js",
    "./package.json": "./package.json"
  }
}

A partir de ahí, cada subpath nombrado es una decisión de arquitectura. Un ./utils, un ./client y un ./server no son solo rutas: son la manera de partir tu librería en fronteras que el usuario importa por separado. Diseñar buenos subpaths es diseñar buena API:

{
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils/index.js",
    "./server": "./dist/server/index.js",
    "./package.json": "./package.json"
  }
}

Hay aquí una tensión de diseño que todo autor debe resolver: el barril único frente a los subpaths granulares. Concentrar todo en la entrada "." con un index.js que reexporta el paquete entero es cómodo de documentar, pero fuerza al bundler del consumidor a analizar todo el grafo para eliminar lo que no usa, y un solo reexport mal marcado arrastra dependencias enteras. Ofrecer subpaths finos —un punto de entrada por función pesada— le da al consumidor fronteras naturales de tree-shaking y de carga diferida. En 2026 la buena práctica para librerías grandes es exponer subpaths por dominio, no un único barril monolítico.

💡
Cada subpath es una frontera de tree-shaking

Al declarar puntos de entrada finitos y conocidos, el bundler del consumidor sabe exactamente qué puede eliminar y los reportes de tamaño pueden atribuir peso por entrada. Un ./charts separado de ./core permite a quien solo usa el núcleo no pagar el coste de los gráficos, aunque olvide configurar nada. La granularidad de tu exports es, en la práctica, el límite superior de lo bien que tu librería puede dividirse en el bundle final de quien la consume.

Patrones y el sellado de lo interno

Enumerar cien componentes uno a uno es insostenible. El comodín * resuelve familias enteras de subpaths, y conviene entenderlo bien: no es un glob, es un marcador posicional que se sustituye literalmente por lo que el consumidor escriba, en la clave y en el valor a la vez. Con él expones una carpeta completa de forma controlada mientras el resto del paquete permanece sellado:

{
  "exports": {
    ".": "./dist/index.js",
    "./components/*": "./dist/components/*.js",
    "./components/internal/*": null,
    "./package.json": "./package.json"
  }
}

@acme/ui/components/button resuelve a ./dist/components/button.js sin listar cada componente. Cuando dos entradas encajan, gana siempre la más específica: por eso ./components/internal/* mapeado a null bloquea esa subcarpeta pese a caer bajo ./components/*. Así abres una carpeta y, dentro de ella, cierras una habitación. El bloqueo tiene además una dimensión de cadena de suministro: si nadie puede importar tus internos, nadie puede acoplarse a ellos ni parchearlos desde fuera.

flowchart TD
A[libreria con muchos archivos internos] --> B[definir el campo exports]
B --> C[entrada principal punto]
B --> D[subpaths nombrados como utils]
B --> E[patron de carpeta components]
B --> F[todo lo demas sellado]
F --> G[refactor libre sin romper semver]
style C fill:#89b4fa,color:#11111b
style E fill:#cba6f7,color:#11111b
style F fill:#f38ba8,color:#11111b
style G fill:#a6e3a1,color:#11111b

Una última herramienta para el autor es la self-reference: dentro del propio paquete puedes importar por tu nombre, @acme/ui/utils, y la resolución pasa por exports igual que para un consumidor externo. Eso te permite probar tu propia frontera desde tus tests, comprobando que lo que crees público lo es y que lo que sellaste está de verdad cerrado, antes de que un usuario lo descubra por ti.

Las decisiones recurrentes de diseño, condensadas en cuatro reglas que separan una superficie madura de una improvisada:

🧱

Subpaths por dominio

Divide en ./client, ./server, ./testing. Cada frontera es un punto de tree-shaking y una promesa que versionar por separado.

🚪

Expon el package.json

Re-exporta "./package.json": "./package.json". Muchas herramientas lo leen en runtime y sin esa línea también queda sellado.

🔒

Sella con null

Bloquea subcarpetas internas con null dentro de un patrón abierto. Lo específico gana, así abres una carpeta y cierras una habitación.

🪞

Prueba con self-reference

Importa tu propia librería por su nombre en los tests. Es la única forma de verificar la frontera como la ve un consumidor externo.

⚠️
El comodín que lo abre todo anula el campo

Un exports con "./*": "./dist/*" parece cómodo, pero devuelve la puerta abierta que exports vino a cerrar: vuelve a exponer el paquete entero, internos incluidos. Si mapeas con comodín, hazlo sobre una carpeta concreta como ./components/*, 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.

📝
imports es el reverso privado de exports

El package.json tiene un campo hermano de exports: el campo imports, cuyos alias empiezan por # y solo resuelven dentro del propio paquete. Donde exports define la puerta pública, imports define los pasillos privados: un #utils o un #config que jamás forma parte de tu superficie externa. Los dos campos parten el mundo en dos audiencias que no se solapan —exports para quien te consume, imports para ti— y ninguno filtra en el otro. Diseñar bien tu librería es diseñar ambos mapas a la vez: uno estrecho hacia fuera, otro cómodo hacia dentro.

La superficie pública es tu decisión de diseño más cara de deshacer

Todo lo demás en tu librería lo puedes cambiar; la superficie pública, una vez publicada, la cambias solo con un cambio mayor y el dolor de miles de builds ajenos. Por eso el diseño de exports es la decisión de arquitectura de mayor palanca que toma un autor. Un exports estrecho y deliberado te compra tres libertades que valen oro. Primero, refactors seguros: puedes reescribir 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, y el semver deja de ser una adivinanza. Tercero, un tree-shaking más agresivo en el consumidor, 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 y hecho cumplir por el runtime. El error del autor novato es exponer de más “por si acaso”, convirtiendo cada archivo interno en una promesa implícita. El autor experto expone lo mínimo, sella el resto, y se reserva así el derecho a que su librería siga viva y evolucionando dentro de cinco años en lugar de quedar congelada por las importaciones accidentales de sus propios usuarios.

⚔️ Diseña tu frontera
  1. Convierte una librería de main a exports con la clave ".", ./package.json y al menos dos subpaths nombrados por dominio.
  2. Sustituye una lista de componentes por un patrón ./components/* y verifica que un subpath concreto resuelve al archivo correcto.
  3. Bloquea ./components/internal/* con null y confirma que importarlo lanza ERR_PACKAGE_PATH_NOT_EXPORTED.
  4. Desde un test del propio paquete, importa por self-reference @tu-scope/lib/utils y comprueba que respeta la frontera.
  5. Cambia el nombre de un archivo bajo dist/internal y confirma que ningún consumidor externo se rompe: esa es la libertad que compraste.