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

Campos base: la identidad del paquete

El manifiesto que todo lo gobierna: name y version como clave inmutable en el registro, el interruptor type que decide entre ESM y CommonJS, y los descriptores heredados main, module y types que aun sostienen la interoperabilidad.

⏱ 16 min

El package.json es el documento constitucional de un paquete de JavaScript: un objeto JSON que actores tan dispares como Node, pnpm, Vite, TypeScript y el registro npm leen para responder, cada uno, a una pregunta distinta. Antes de dominar dependencias, scripts o exports, conviene dominar el puñado de campos que definen qué es el paquete —su nombre, su versión, su naturaleza de módulos y los punteros a su código—. Equivocarse aquí rara vez produce un error ruidoso: produce comportamientos silenciosos y desconcertantes muchas capas más abajo.

🎯 Al terminar esta lección sabrás
  • Fijar name y version como la clave primaria e inmutable del paquete en el registro.
  • Comprender cómo el campo type decide la interpretación de cada archivo .js entre ESM y CommonJS.
  • Distinguir el papel de main, module y types, y por qué conviven pese a estar casi obsoletos.
  • Redactar un encabezado de manifiesto correcto y sin ambigüedades para un paquete de 2026.

name y version: la clave primaria del registro

El par formado por name y version no es meramente descriptivo: es la coordenada única e inmutable con la que un registro identifica cada artefacto. En el momento en que publicas @acme/motor@2.4.1, ese tarball queda congelado para siempre; el registro no permite reutilizar la combinación exacta, y la retirada (unpublish) está severamente restringida para no romper a quien ya dependía de ella. Interiorizar esto cambia cómo versionas: una versión no es una etiqueta editable, es una promesa pública.

El campo name obedece reglas estrictas heredadas de su uso como identificador en URLs y sistemas de ficheros:

  • Todo en minúsculas, sin espacios; solo caracteres seguros para una URL.
  • No puede empezar por punto ni por guion bajo, ni contener mayúsculas.
  • Longitud máxima de 214 caracteres, incluido el ámbito o scope.
  • Puede llevar ámbito con la forma @acme/motor, que agrupa paquetes y habilita permisos y publicación restringida.

El ámbito no es mera cosmética organizativa: un paquete con ámbito nace privado por defecto en el registro y necesita una autorización explícita para hacerse público, lo que reduce el riesgo de publicar por error algo interno. Además, agrupa la propiedad y los permisos de todo un conjunto de paquetes bajo una misma cuenta u organización.

{
  "name": "@acme/motor",
  "version": "2.4.1",
  "description": "Motor de reglas para el pipeline de Acme.",
  "license": "MIT",
  "private": false
}

El campo version debe ser un semver estricto —MAJOR.MINOR.PATCH— que el parser node-semver acepte sin ambigüedad; cualquier desviación rompe la publicación. Si el proyecto es una aplicación interna o un paquete de un monorepo que nunca se sube al registro, marca "private": true: es el pestillo que impide una publicación accidental y, de paso, autoriza campos de workspace que el registro rechazaría.

⚠️
Publicar es (casi) irreversible

El registro trata cada versión publicada como inmutable: no puedes sobrescribir 2.4.1 con otro contenido, y la retirada (unpublish) solo se permite en una ventana muy corta tras publicar y bajo condiciones estrictas, precisamente para no romper a quien ya dependía de ella. La herramienta correcta para decir “esta versión no debe usarse” no es borrar, sino npm deprecate, que deja un aviso sin retirar el artefacto. Trata cada pnpm publish como definitivo, porque en la práctica lo es.

type: el interruptor entre ESM y CommonJS

Ningún campo base tiene consecuencias tan profundas como type. Determina cómo interpreta Node —y, por extensión, casi todo el toolchain— cada archivo con extensión .js dentro del paquete. Con "type": "module", esos archivos son módulos ES: usan import y export, y carecen de require, __dirname o module.exports. Con "type": "commonjs" o en ausencia del campo, son CommonJS clásico. Las extensiones explícitas .mjs y .cjs fuerzan su naturaleza por archivo y ganan siempre, sin importar lo que diga type.

flowchart TD
JS[Archivo .js] --> T{campo type}
T -->|module| E[Interpretado como ESM]
T -->|commonjs o ausente| C[Interpretado como CommonJS]
M[Archivo .mjs] --> E
X[Archivo .cjs] --> C

En 2026, "type": "module" es la elección por defecto sensata para todo trabajo nuevo: el ecosistema asume ESM, los bundlers lo prefieren y las herramientas modernas nacen sin CommonJS. La antigua fricción del dual package se ha suavizado —Node ya permite require() de un grafo ESM síncrono sin flag experimental—, pero el campo type sigue gobernando la interpretación de cada .js y sigue siendo la primera causa de errores como require is not defined o Cannot use import statement outside a module. Cuando algo no cuadra, type es el primer sospechoso.

ℹ️
Cuándo recurrir a `.mjs` y `.cjs`

Fijar type cubre el caso normal, pero a veces necesitas un archivo que contradiga al paquete: un script de configuración en CommonJS dentro de un paquete ESM, o un módulo ESM aislado en una base heredada. Para eso están las extensiones explícitas .mjs —siempre ESM— y .cjs —siempre CommonJS—: ganan a type sin discusión y permiten migrar archivo a archivo, sin voltear el paquete entero de golpe. Son la palanca que hace posible una transición gradual a ESM en bases de código grandes, donde un cambio atómico sería inviable.

El campo condiciona también a las herramientas que generan .js: un compilador de TypeScript o un bundler consultan type para decidir si emiten import o require, de modo que una discrepancia entre lo que declaras y lo que tu build produce se paga en tiempo de ejecución, no de compilación —justo cuando menos lo esperas—.

Los tres síntomas que delatan un type mal ajustado y que conviene reconocer al vuelo:

  • require is not defined o exports is not defined: código CommonJS interpretado como ESM.
  • Cannot use import statement outside a module: lo contrario, ESM interpretado como CommonJS.
  • __dirname is not defined: un archivo ESM esperando globales que solo existen en CommonJS.

main, module, types y el resto del encabezado

Antes de que existiera el mapa exports, tres campos apuntaban al código del paquete, y todavía sobreviven como red de compatibilidad:

  • main es el punto de entrada clásico: lo que se resuelve al importar el paquete sin subruta cuando no hay exports. Su valor por defecto es index.js.
  • module nunca fue estandarizado por Node, que lo ignora por completo; es una convención que los bundlers (Rollup, webpack, Vite) honran para elegir una build ESM apta para tree shaking.
  • types (o typings) indica a TypeScript dónde está la declaración .d.ts de entrada.
{
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

Un paquete robusto acompaña cada punto de entrada de su declaración de tipos y evita que main, module y exports cuenten historias distintas sobre dónde está el código. Cuando conviven, exports manda para el Node moderno y main queda como respaldo para tooling antiguo; mantenerlos coherentes es lo que impide que un consumidor cargue una build mientras su verificador de tipos analiza otra. Un quinto descriptor heredado, browser, permitía sustituir módulos por variantes aptas para el navegador; hoy su función la absorbe la condición browser dentro de exports, pero aún lo encontrarás vivo en paquetes veteranos.

El campo description es prosa libre que alimenta la búsqueda del registro, y license debe ser un identificador SPDX válido —MIT, Apache-2.0, BSD-3-Clause— o UNLICENSED para código privado; no es una frase, sino un código que las auditorías de licencias leen automáticamente. Estos punteros heredados no desaparecen, pero en un paquete moderno quedan subordinados al mapa exports, que veremos en la última lección de este nivel y que se impone a main cuando ambos existen.

Metadatos para el registro y las auditorías

Más allá del núcleo funcional, un puñado de campos describe la procedencia y el contexto del paquete. No cambian cómo se ejecuta el código, pero alimentan la ficha del registro, las herramientas de descubrimiento y las auditorías de seguridad y licencias que hoy son parte inseparable de cualquier cadena de suministro seria.

{
  "keywords": ["reglas", "pipeline", "validacion"],
  "author": "Acme Engineering <dev@acme.dev>",
  "homepage": "https://acme.dev/motor",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/acme/motor.git"
  },
  "bugs": {
    "url": "https://github.com/acme/motor/issues"
  }
}
  • keywords mejora la búsqueda dentro del registro; una lista corta y precisa vale más que un saco de términos.
  • repository es cada vez más crítico: habilita la provenance y permite a las auditorías verificar que el tarball publicado procede del código que dice proceder.
  • homepage y bugs orientan a quien consume el paquete hacia la documentación y el canal de incidencias correctos.
  • author y contributors atribuyen la autoría y, junto a license, cierran la ficha legal del paquete.

En conjunto, estos metadatos convierten un paquete anónimo en uno auditable y confiable, algo que en 2026 no es un adorno sino un requisito para que muchas organizaciones acepten siquiera instalarlo.

💡
El manifiesto mínimo publicable

Para publicar, npm exige realmente poco: bastan un name y una version válidos. Pero un paquete profesional no se queda en el mínimo legal: declara type, license, description, exports, repository y engines, porque cada uno de esos campos ausentes es una pregunta que trasladas a quien te consume o a la herramienta que te audita.

🏷️

name

Minúsculas, seguro para URL, hasta 214 caracteres, con ámbito opcional. Junto a version, la clave inmutable del registro.

🔢

version

Semver estricto MAJOR.MINOR.PATCH. Una vez publicada, congelada: es una promesa, no una etiqueta.

🔀

type

module o commonjs. Decide cómo se interpreta cada .js; .mjs y .cjs mandan por encima.

📄

license

Un identificador SPDX, no una frase. Lo leen herramientas de auditoría, no solo humanos.

El manifiesto es un contrato con muchos lectores

La clave para entender package.json es que no tiene un solo lector, sino varios, y cada campo cuenta su historia a un público diferente. El registro npm lee name y version para archivar el tarball; Node lee type para decidir el lenguaje de cada archivo; el bundler lee module para elegir la build que mejor se poda; el compilador de TypeScript lee types para encontrar las declaraciones; y una auditoría de dependencias lee license para aprobar o vetar el paquete. La inmensa mayoría de los bugs de empaquetado —esos que se manifiestan como works on my machine— nacen de una contradicción entre las historias que cuentan estos campos: un type: module cuyo main apunta a un artefacto CommonJS, una version que promete compatibilidad que el código ya rompió, un types que describe una API distinta de la que exporta el runtime. Diseñar bien un paquete no es rellenar campos: es garantizar que todos los lectores del manifiesto reciban una versión coherente de la misma verdad. Esa coherencia, y no la mera validez sintáctica del JSON, es lo que distingue a un paquete profesional de uno que funciona por casualidad.

⚔️ Radiografía de un manifiesto real
  1. Abre el package.json de una dependencia instalada en node_modules y localiza name, version, type, main, module y types.
  2. Determina si el paquete es ESM, CommonJS o dual, y contrasta si main y module apuntan a artefactos coherentes con el valor de type.
  3. En un proyecto tuyo, cambia type de ausente a module y observa qué archivos .js dejan de compilar; entiende cada error como una pista sobre qué asumía CommonJS.
  4. Redacta desde cero el encabezado de un paquete publicable: nombre con ámbito, versión inicial 0.1.0, type explícito, license SPDX y description de una línea.