wandres.dev
PACKAGE.JSON A FONDO · campos y scripts

Campos modernos: engines, exports y compania

El package.json de 2026: engines y packageManager para clavar el entorno con Corepack, exports como frontera de encapsulacion y resolucion condicional, y files, sideEffects y publishConfig para controlar que publicas, como se poda y a donde va.

⏱ 18 min

Los campos base definen la identidad del paquete; los modernos definen su comportamiento profesional. engines y packageManager clavan el entorno para que nadie construya con la herramienta equivocada; exports reemplaza al viejo main por un mapa que a la vez encapsula el paquete y resuelve la entrada correcta según el consumidor; y files, sideEffects y publishConfig gobiernan qué se publica, cómo se poda y con qué política. Dominar estos campos es la diferencia entre un paquete que funciona y uno que se puede mantener, auditar y optimizar a escala.

🎯 Al terminar esta lección sabrás
  • Clavar el entorno de ejecución con engines y el gestor exacto con packageManager y Corepack.
  • Diseñar un mapa exports con resolución condicional y entender su papel como frontera de encapsulación.
  • Controlar el contenido del tarball publicado con files y habilitar el tree shaking con sideEffects.
  • Ajustar la publicación —acceso, registro, procedencia— con publishConfig.

engines y packageManager: clavar el entorno

El campo engines declara qué versiones de Node o del gestor soporta el paquete. Por defecto es un aviso, pero con engine-strict activo se convierte en una barrera que aborta la instalación si el entorno no cumple. packageManager va un paso más allá: fija el gestor y su versión exacta, y Corepack —incluido en Node— lo lee para aprovisionar automáticamente ese pnpm concreto, de modo que todo el equipo y el CI usen el mismo binario sin instalarlo a mano.

{
  "engines": {
    "node": ">=20.11",
    "pnpm": ">=9"
  },
  "packageManager": "pnpm@9.12.0"
}

La combinación elimina de raíz una clase entera de bugs: los que solo aparecen porque alguien construyó con una versión distinta del runtime o del gestor. El entorno deja de ser una variable oculta y pasa a ser parte del manifiesto, versionada junto al código.

corepack enable      # activa el shim de Corepack, incluido en Node
pnpm install         # usa exactamente el pnpm que fija packageManager

Con engine-strict activo, un engines incumplido aborta la instalación en lugar de limitarse a avisar; en una librería pública es prudente declararlo laxo para no excluir consumidores, y en una aplicación interna, estricto para forzar homogeneidad.

engines documenta y opcionalmente exige la versión, pero no la instala. De eso se encargan Corepack para el gestor y ficheros como .nvmrc o .node-version para el runtime, que los gestores de versiones de Node leen para cambiar solos. Declarar ambos —la restricción en engines y la versión concreta en el fichero— cubre a la vez la validación y la conveniencia.

exports: la frontera de encapsulación

exports es el cambio más profundo del package.json moderno. Sustituye a main con un mapa que hace dos cosas a la vez. Primero, encapsula: solo las subrutas que declaras son importables; cualquier intento de acceder a un archivo interno no listado falla con ERR_PACKAGE_PATH_NOT_EXPORTED. Segundo, resuelve condicionalmente: elige un artefacto distinto según quién importe —el compilador de tipos, un entorno ESM, uno CommonJS, el navegador—.

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./plugin": {
      "types": "./dist/plugin.d.ts",
      "import": "./dist/plugin.js"
    },
    "./package.json": "./package.json"
  }
}

Fíjate en la entrada ./package.json del ejemplo: al encapsular con exports dejarías de poder importar tu propio manifiesto, y muchas herramientas lo necesitan en tiempo de ejecución, así que reexponerlo de forma explícita es una práctica habitual y recomendada.

El orden de las condiciones importa: se evalúan de arriba abajo y gana la primera que coincide, por lo que types debe ir siempre primera y default siempre última como red de seguridad. Las condiciones más habituales, en el orden en que suelen declararse:

  • types: la ruta al .d.ts; primera para que TypeScript la resuelva antes que nada.
  • node y browser: artefactos distintos según el entorno de ejecución.
  • development y production: builds con o sin comprobaciones y avisos, que el bundler elige por el modo.
  • import y require: el archivo ESM frente al CommonJS según cómo se cargue el paquete.
  • default: la última red de seguridad, que siempre debe existir.
flowchart TD
A[Peticion del consumidor] --> B{subruta declarada en exports}
B -->|no declarada| E[Error PACKAGE PATH NOT EXPORTED]
B -->|declarada| C[Evalua condiciones en orden]
C --> D1[types para el compilador]
D1 --> D2[node o browser segun entorno]
D2 --> D3[import o require segun sistema de modulos]
D3 --> D4[default como ultimo recurso]

La contrapartida es una responsabilidad nueva: si expones a la vez una build ESM y una CommonJS de la misma librería con estado, un consumidor podría cargar ambas y acabar con dos instancias —el temido dual package hazard—. La encapsulación de exports ayuda a acotarlo, pero el diseño del paquete debe evitar duplicar estado entre ramas.

ℹ️
`imports`: subrutas internas privadas

El primo interno de exports es imports, un mapa cuyas claves empiezan por # y que solo es visible dentro del propio paquete. Sirve para dar un alias estable a rutas internas —#config, #utils— o para resolver condicionalmente un módulo interno según el entorno, sin exponer nada al exterior. Es la forma canónica y estándar de tener alias de importación sin depender de la configuración del bundler, y por eso sobrevive fuera de él y viaja con el paquete.

files, sideEffects y publishConfig

Estos tres campos gobiernan el paquete que realmente sale por la puerta:

  • files es una lista blanca de lo que entra en el tarball publicado. Complementa a .npmignore invirtiendo la lógica: en vez de enumerar lo que se excluye, enumeras lo que se incluye. Algunos archivos entran siempre —package.json, el README, la licencia y el punto de entrada—, pero todo lo demás debe estar en la lista o no viajará.
  • sideEffects le dice al bundler si los módulos del paquete tienen efectos colaterales. Con false, autorizas un tree shaking agresivo: el bundler puede descartar cualquier módulo cuyas exportaciones no se usen. Si algunos archivos sí tienen efectos —hojas de estilo importadas por su efecto, polyfills—, se declaran como un array de globs para protegerlos.
  • publishConfig sobrescribe ajustes solo en el momento de publicar: el registry de destino, el access (public para un paquete con ámbito que quieres abierto), el tag de distribución y la provenance que ata el artefacto a su origen en CI.
{
  "files": ["dist", "README.md"],
  "sideEffects": ["**/*.css"],
  "publishConfig": {
    "access": "public",
    "provenance": true
  }
}

El matiz de sideEffects es sutil pero de alto impacto en el peso final: declarar false cuando un módulo sí produce efectos —registra un customElement, aplica un polyfill— hace que el bundler lo elimine y rompe la app en silencio. La lista de globs existe para marcar esas excepciones; el caso más común son los .css que se importan por su efecto de aplicar estilos, no por lo que exportan.

La provenance merece mención aparte: al publicar desde un CI compatible, npm firma una atestación verificable que ata el tarball al commit y al workflow que lo produjeron, siguiendo el estándar SLSA. No evita que un paquete sea malicioso, pero permite comprobar criptográficamente que procede de donde dice: una respuesta directa a los ataques de cadena de suministro.

Recuerda, además, qué entra siempre en el tarball declares lo que declares en files:

  • package.json, sin excepción posible.
  • El README y el archivo de licencia.
  • El punto de entrada señalado por main o por exports.
💡
Verifica el tarball antes de publicar

Nunca publiques a ciegas. pnpm pack genera el .tgz exacto que subirías, y npm publish --dry-run lista cada archivo incluido sin llegar a publicar. Es la única forma de descubrir a tiempo que estás filtrando un .env, tus tests o medio src/ que files debería haber excluido. Un minuto de inspección evita una fuga irreversible: recuerda que una versión publicada no se puede corregir, solo deprecar.

🔧

engines · packageManager

Clavan runtime y gestor. Con Corepack, todo el equipo usa el mismo pnpm sin instalarlo a mano.

🚪

exports

Encapsula el paquete y resuelve la entrada por condición. Su superficie es API pública.

📮

files

Lista blanca de lo que entra en el tarball. Lo que no está, no se publica.

🌳

sideEffects

false autoriza tree shaking agresivo; un array de globs protege lo que sí tiene efectos.

exports es una API tan pública como tus funciones

El giro mental que exige el package.json moderno es dejar de ver exports como un simple puntero al código y empezar a verlo como parte de la superficie pública del paquete, tan vinculante como la firma de tus funciones. Antes de exports, todo archivo publicado era importable: los consumidores llegaban a tus internos por rutas profundas, se acoplaban a detalles de implementación y, sin saberlo, convertían cada refactor tuyo en un breaking change para ellos. Al declarar un mapa de exportaciones cierras esa puerta: lo que no está en el mapa no existe para el exterior, y recuperas la libertad de reorganizar tu dist sin romper a nadie. Pero esa frontera es un contrato: añadir una subruta es una feature, quitarla o renombrarla es un cambio incompatible que obliga a subir la major, exactamente igual que si borraras una función exportada. Lo mismo vale para las condiciones: el orden en que colocas types, import y require decide qué recibe cada consumidor, y una condición mal ordenada puede servir CommonJS a quien pidió ESM y desatar el dual package hazard. Junto a engines y packageManager, que convierten el entorno en algo declarado y no supuesto, y a files, que hace explícito lo que publicas, estos campos marcan la transición de “un paquete que funciona en mi máquina” a “un paquete diseñado para que funcione en las de todos”. Esa intención deliberada, y no la cantidad de campos rellenados, es lo que define a un paquete de nivel Dios.

⚔️ Moderniza un manifiesto entero
  1. Añade engines y packageManager a un proyecto y ejecuta corepack enable; comprueba que el gestor se aprovisiona solo con la versión fijada.
  2. Convierte un main heredado en un mapa exports con condiciones types, import y require, y expón una única subruta secundaria bajo ./.
  3. Intenta importar un archivo interno no declarado en exports y confirma que falla con ERR_PACKAGE_PATH_NOT_EXPORTED; entiende esa negativa como encapsulación, no como error.
  4. Ejecuta pnpm pack y abre el tarball resultante: contrasta su contenido con tu campo files y elimina cualquier archivo que no debería viajar al registro.