wandres.dev
PNPM CATALOGS · versiones compartidas

El protocolo catalog: resolución y reescritura al publicar

Cómo se resuelve el protocolo catalog en la instalación —del marcador al rango del catalogo a la versión concreta del lockfile— y cómo pnpm publish y pnpm pack lo reescriben a un rango real para que un consumidor externo, que no tiene tu catalogo, reciba un paquete autocontenido.

⏱ 15 min

catalog: no es una versión: es un puntero a una versión. Y como todo puntero, hay dos preguntas que gobiernan su comportamiento: cómo se desreferencia cuando instalas dentro del monorepo, y qué pasa con él cuando el paquete cruza la frontera del repo al publicarse en npm, donde nadie tiene tu catálogo para resolverlo. La respuesta a la segunda —pnpm lo reescribe a un rango concreto al publicar— es lo que hace que los catalogs sean seguros para librerías que publicas, y no solo para apps internas. Entender esta indirección es entender por qué el protocolo es una comodidad interna que jamás se filtra al artefacto final.

🎯 Al terminar esta lección sabrás
  • Ver catalog: como una indirección: un puntero a un rango, no un rango.
  • Seguir la resolución en la instalación: marcador, rango del catálogo, versión del lockfile.
  • Entender la reescritura de catalog: a un rango concreto en pnpm publish y pnpm pack.
  • Reconocer la simetría con el protocolo workspace: y por qué ninguno ata a tus consumidores.

Un protocolo es una forma de especificar, no una versión

En el campo de una dependencia, pnpm admite varias formas de especificar de dónde sale ese paquete: un rango de semver (^18.3.1), una ruta de archivo (file:../x), un enlace de workspace (workspace:*) o una entrada de catálogo (catalog:). Todas ocupan el mismo hueco sintáctico, pero solo el rango de semver es una versión de verdad; los demás son protocolos: instrucciones que le dicen a pnpm dónde ir a buscar la versión real. catalog: es una de esas instrucciones, y significa “ve al catálogo del workspace y usa lo que allí esté declarado para este paquete”.

La potencia de la idea está en que todos estos protocolos comparten el mismo hueco —el campo de versión— y por tanto son intercambiables sin cambiar la forma del package.json. Puedes empezar con ^18.3.1, pasar a catalog: cuando centralices, y en ambos casos el campo sigue siendo un campo de versión bien formado. El protocolo no es una sintaxis paralela ni un formato ajeno: es una extensión del mismo lugar donde siempre vivió la versión, y esa continuidad es la razón de que se integre sin fricción con todo el resto del ecosistema.

Merece nombrar los protocolos que comparten ese hueco, porque forman un vocabulario coherente: catalog: apunta al catálogo del workspace, workspace: a un paquete hermano del monorepo, file: o link: a una ruta del disco, y un rango de semver al registro público. Todos responden a la misma pregunta —de dónde sale esta dependencia— con orígenes distintos, y todos, salvo el rango de semver, son internos al repo.

💡
El rango de semver es el único que sobrevive a la publicación

De las formas de especificar una dependencia, solo el rango de semver es universal: existe igual dentro y fuera del workspace. Los demás protocolos —catalog:, workspace:, file:— son conveniencias internas que pnpm traduce a un rango de semver al empaquetar. Si interiorizas que “todo lo interno se convierte en semver al publicar”, predices sin esfuerzo qué verá tu consumidor en cualquier caso.

Cómo se resuelve catalog: en la instalación

Cuando corres pnpm install, el protocolo se desreferencia en dos saltos. Primero, catalog: se expande al rango declarado en el catálogo —por ejemplo ^18.3.1—. Después, ese rango se resuelve, como cualquier otro, a la versión exacta disponible en el registro —por ejemplo 18.3.1—, y esa versión concreta es la que queda anclada en pnpm-lock.yaml.

catalogs:
  default:
    react:
      specifier: ^18.3.1
      version: 18.3.1
importers:
  apps/web:
    dependencies:
      react:
        specifier: 'catalog:'
        version: 18.3.1

Fíjate en el detalle revelador: el lockfile guarda una sección catalogs que fotografía el rango del catálogo y la versión a la que se resolvió, y en cada importer registra que el specifier era catalog: pero la version efectiva es 18.3.1. El puntero y su destino quedan ambos anotados. Por eso el lockfile sigue siendo la fuente de reproducibilidad: aunque el package.json diga solo catalog:, el lockfile sabe exactamente qué versión se instaló.

flowchart LR
A[package.json dice catalog] --> B[pnpm expande al rango del catalogo]
B --> C[resuelve el rango a version exacta]
C --> D[lockfile ancla la version concreta]
style A fill:#89b4fa,color:#11111b
style D fill:#a6e3a1,color:#11111b

Esta separación entre lo que declara el package.json y lo que ancla el lockfile es la que preserva la reproducibilidad tras adoptar catalogs. Un pnpm install --frozen-lockfile en CI no consulta el catálogo para decidir versiones: instala exactamente lo que el lockfile ya fijó, y usa la sección catalogs del lockfile solo para verificar que el catálogo del pnpm-workspace.yaml no ha cambiado por debajo. Si alguien edita el catálogo y no regenera el lockfile, la instalación frozen falla —que es justo lo que quieres—: el lockfile y el catálogo deben contar siempre la misma historia, y la que no cuadra se detecta antes de desplegar.

📝
Actualizar el catálogo, no cada paquete

Como el rango vive en un solo sitio, subir una versión es editar una línea del catálogo y reinstalar; todos los paquetes que la referencian con catalog: heredan el cambio a la vez. Y la indirección no te esconde nada de tu herramienta habitual: pnpm outdated sigue reportando estas dependencias con normalidad y pnpm update puede tocar la entrada del catálogo directamente. Lo único que cambia es que el punto donde vive la versión se colapsó de N sitios a uno.

La reescritura al publicar

Aquí está la pieza que hace todo el sistema viable para librerías. El protocolo catalog: solo tiene sentido dentro del workspace que define el catálogo. Un consumidor externo que instale tu paquete desde npm no tiene tu pnpm-workspace.yaml y no sabría qué significa catalog:. Por eso pnpm lo elimina al empaquetar: tanto pnpm publish como pnpm pack reescriben cada catalog: al rango concreto que tenía en el catálogo en ese momento.

{
  "name": "@acme/ui",
  "dependencies": {
    "react": "catalog:react18"
  }
}
{
  "name": "@acme/ui",
  "dependencies": {
    "react": "^18.3.1"
  }
}

El artefacto que llega a npm es autocontenido: no contiene rastro del protocolo catalog:, sino el rango ^18.3.1 que cualquier package manager entiende. Tu comodidad interna —escribir catalog: y no repetir números— no se filtra al mundo exterior, que recibe un package.json perfectamente normal. Es una transformación de tiempo de publicación, análoga a compilar: el fuente usa una abstracción, el artefacto entregado ya no la necesita.

Repara en qué exactamente se escribe: el rango del catálogo, no la versión exacta que instalaste. Si el catálogo dice ^18.3.1, eso es lo que ve tu consumidor, conservando la flexibilidad de semver que tú elegiste al definir el rango. El catálogo no endurece de más las dependencias de tu paquete publicado; simplemente traslada al artefacto el mismo rango que habrías escrito a mano, pero garantizado idéntico al que usa el resto de tu repo.

# comprobarlo tu mismo, sin publicar nada:
pnpm pack                       # genera el tarball .tgz
tar -xzf acme-ui-1.0.0.tgz      # lo extraes
cat package/package.json        # y veras el rango concreto, no catalog:
⚠️
La reescritura congela el rango del momento del empaquetado

El rango que se escribe en el paquete publicado es el que el catálogo tenía cuando ejecutaste pnpm publish. Si más tarde subes la entrada del catálogo, las versiones ya publicadas no cambian —son inmutables en el registro—. Por eso el flujo sano es actualizar el catálogo y después publicar, y por eso conviene que la publicación sea automática en CI a partir del estado real del repo, no un acto manual que podría capturar un catálogo a medio actualizar.

Simetría con workspace: y por qué ninguno te ata

Si esta mecánica te suena, es porque ya la viste con workspace:. Cuando una librería interna depende de otra con workspace:*, pnpm reescribe ese marcador a la versión real del paquete hermano al publicar. catalog: sigue exactamente el mismo patrón, y no es casualidad: ambos son protocolos que solo existen dentro del workspace y ambos se borran en la frontera de publicación, sustituyéndose por lo que un consumidor externo puede entender.

// en el repo
{ "dependencies": { "react": "catalog:", "@acme/core": "workspace:*" } }

// publicado
{ "dependencies": { "react": "^18.3.1", "@acme/core": "1.4.2" } }

De aquí sale la consecuencia estratégica más importante: adoptar catalogs no ata a quien te consume. Como el marcador desaparece al publicar, el paquete que llega a npm no menciona catalogs en ninguna parte, y quien lo instale con npm, yarn o bun recibe rangos de semver ordinarios sin enterarse de que tú usaste un catálogo para producirlo. El coste de adopción recae enteramente en ti, el mantenedor; el beneficio de interoperabilidad se conserva intacto para el consumidor. Puedes catalogar una librería pública sin miedo a fragmentar a su comunidad ni a encadenarla a pnpm.

Esta propiedad convierte a los catalogs en una de esas raras mejoras que puedes adoptar de forma unilateral. No necesitas coordinar con tus consumidores, ni publicar una versión mayor, ni documentar una migración para quien te usa: el cambio es puramente interno a tu repo, y el mundo exterior recibe exactamente lo que recibía antes. Pocas decisiones de arquitectura son a la vez tan beneficiosas para el mantenedor y tan intrascendentes para el consumidor, y esa asimetría es la mejor señal de que la abstracción está trazada en el sitio correcto.

🏠

Dentro del workspace

catalog: es un puntero al catálogo. Una fuente de verdad, cero repetición, cero deriva entre paquetes.

🔒

En el lockfile

Queda anclada la versión exacta resuelta. La reproducibilidad se conserva pese a que el manifiesto solo diga catalog:.

🚀

Al publicar

pnpm publish y pnpm pack reescriben catalog: al rango concreto. El tarball es autocontenido.

🌍

En el consumidor

Recibe un package.json normal. npm, yarn o bun lo instalan sin saber que existió un catálogo.

El protocolo es una indirección de tiempo de publicación: interna al escribir, borrada al entregar

La belleza de catalog: está en dónde ocurre su magia. No es una indirección de runtime que el consumidor tenga que resolver, ni un formato propietario que ate a quien instale tu paquete a usar pnpm. Es una indirección que existe solo mientras el código vive dentro de tu workspace, y que se resuelve y desaparece en el instante exacto en que el paquete cruza al exterior. Dentro, disfrutas de la fuente única de verdad: un rango, un sitio, cero deriva. Fuera, entregas un package.json ortodoxo que npm, yarn, bun o cualquier resolvedor tratan sin saber que jamás existió un catálogo. Esta separación entre la representación interna —optimizada para el mantenedor— y el artefacto entregado —optimizado para la interoperabilidad— es uno de los patrones más profundos de la ingeniería de sistemas, el mismo que separa el código fuente del binario o el módulo del bundle. Y tiene un corolario práctico crucial: como el lockfile ancla la versión concreta y la publicación reescribe el rango, adoptar catalogs no compromete ni la reproducibilidad interna ni la compatibilidad externa. Ganas la centralización sin pagar con acoplamiento. Cuando una abstracción te da poder dentro y se vuelve invisible fuera, sabes que está bien diseñada.

⚔️ Sigue el puntero de punta a punta
  1. Declara react: catalog: en un paquete, instala, y abre pnpm-lock.yaml: localiza la sección catalogs y el specifier frente a la version en el importer.
  2. Ejecuta pnpm pack en una librería que use catalog:, extrae el .tgz y confirma que el package.json del tarball ya no contiene catalog: sino un rango concreto.
  3. Cambia el rango del catálogo, vuelve a empaquetar sin publicar, y compara: comprueba que el tarball refleja el rango nuevo.
  4. En un paquete que dependa tanto de catalog: como de workspace:*, empaqueta y verifica que ambos protocolos se reescriben a la vez.
  5. Explica en dos frases por qué un consumidor que instala tu paquete con npm nunca necesita conocer tu catálogo.