wandres.dev
PNPM CATALOGS · versiones compartidas

El problema que resuelven los catalogs

La deriva de versiones en un monorepo: la misma dependencia fijada con rangos distintos en decenas de package.json, y su coste real en bugs de runtime, peso de bundle, upgrades tediosos y conflictos de merge.

⏱ 13 min

En un monorepo con treinta paquetes, react no aparece una vez: aparece treinta. Basta con que tres de esas declaraciones digan ^18.2.0, ^18.3.0 y ~18.2.1 para que tu grafo termine instalando dos copias de React que se ignoran entre sí. El problema no es estético: la versión de una dependencia es estado compartido disperso por decenas de archivos que nadie edita a la vez, y el estado compartido sin dueño siempre deriva. Los catalogs de pnpm existen para convertir ese estado disperso en una única fuente de verdad.

🎯 Al terminar esta lección sabrás
  • Nombrar la deriva de versiones y por qué un monorepo la incuba.
  • Entender por qué duplicar una dependencia es un bug de runtime, no solo peso.
  • Medir el coste humano: upgrades archivo por archivo y conflictos de merge.
  • Situar los catalogs como la fuente única de verdad de las versiones.

La deriva de versiones

Un workspace de pnpm da a cada paquete su propio package.json, y con él la libertad de declarar sus propias dependencias. Esa autonomía es deseable hasta que se vuelve fragmentación. Cuando escribes pnpm add zod en app-web el lunes y en ui-kit el jueves, pnpm fija en cada uno el último rango disponible en ese momento, y esos momentos no coinciden. Nadie decidió tener dos rangos de zod: emergieron de la asincronía de veinte personas trabajando en paralelo. A esto se le llama deriva de versiones —version drift o dependency skew—: la divergencia silenciosa de una misma dependencia declarada en muchos sitios sin un dueño que la unifique.

// apps/web/package.json
{ "dependencies": { "react": "^18.2.0" } }

// apps/admin/package.json
{ "dependencies": { "react": "^18.3.0" } }

// packages/ui-kit/package.json
{ "dependencies": { "react": "~18.2.1" } }

Nadie escribió esto de una sentada; se acumuló commit a commit. Y como cada rango se resolvió en un instante distinto, el grafo de instalación no tiene por qué converger en una sola versión.

flowchart TD
A[app-web quiere react 18.2.0] --> S[grafo de instalacion]
B[app-admin quiere react 18.3.0] --> S
C[ui-kit quiere react 18.2.1] --> S
S --> D[dos copias de react en node_modules]
style D fill:#eba0ac,color:#11111b

El grafo no miente: si los rangos no solapan en una única versión, pnpm resuelve más de una, y las instala. Lo que en un solo paquete sería imposible —dos versiones de React a la vez— en un monorepo es el estado hacia el que se tiende sin disciplina. Y hay una ironía: el node_modules estricto de pnpm, que no aplana ni miente sobre el árbol como hacía el hoisting de npm, es más honesto y por eso expone la doble copia en vez de ocultarla. La deriva siempre estuvo ahí; pnpm solo deja de disimularla.

Duplicar una dependencia es un bug, no solo peso

El instinto dice que dos copias de una librería solo cuestan disco y bytes de bundle. Para muchas dependencias es cierto; para las que mantienen estado global o identidad, es un fallo de correctitud. React es el ejemplo canónico: dos copias tienen dos registros internos de hooks distintos, y un componente renderizado por una copia y consumido por otra rompe con el célebre Invalid hook call. Lo mismo ocurre con la identidad de un Context: el createContext de la copia A no es el de la copia B, así que un Provider de una no alimenta al useContext de la otra, y el valor llega vacío sin ningún error de tipos que lo delate.

El patrón se repite en toda dependencia que asume ser un singleton: un cliente de estado cuyo store se duplica, una instancia de zod cuyos instanceof dejan de casar, una librería de estilos con su registro global de clases. El bug es especialmente cruel porque el checker de tipos no ve nada: cada paquete importó “la” librería y su tipo es correcto; solo en runtime, con dos copias físicas cargadas, la invariante de unicidad se viola. Depurarlo es un infierno precisamente porque el síntoma —un hook que falla, un contexto vacío— aparece lejísimos de la causa —dos rangos que no solapaban en un package.json que nadie miró—.

# la evidencia forense: el grafo confiesa dos versiones
pnpm why react
# react 18.2.0  <- de apps/web
# react 18.3.0  <- de apps/admin

Y aunque la librería no sea un singleton y no rompa nada, la doble copia sigue costando: el bundler no puede deduplicar lo que resuelve a versiones distintas, así que el usuario final descarga y ejecuta el mismo código dos veces. La deriva que en el peor caso es un bug, en el mejor caso es peso muerto que paga tu usuario.

⚠️
Una sola copia no es una preferencia: es una invariante

Para React, tu librería de estado o cualquier paquete con estado global, tener exactamente una versión en el grafo no es una optimización opcional sino una condición de correctitud. Los peerDependencies existen precisamente para expresar esto —“yo no traigo React, uso el tuyo”—, pero solo funcionan si el consumidor final logra converger en una única versión. La deriva de versiones sabotea esa convergencia desde dentro del propio repo, justo donde más control creías tener.

ℹ️
Deduplicar el árbol no cura la deriva declarada

Es tentador pensar que pnpm dedupe resuelve esto. Deduplica lo que puede: si dos rangos solapan en una versión común, los unifica. Pero no puede unificar lo que no solapa —^17 y ^18 no tienen intersección— ni cambia lo que cada package.json declara. La deduplicación actúa sobre el árbol ya resuelto; la deriva vive una capa más arriba, en los rangos declarados, y solo se cura ahí. Por eso el arreglo de fondo no es deduplicar mejor, sino declarar una sola vez.

Cómo se ve en el node_modules

Conviene aterrizar la abstracción en algo palpable. Con dos rangos incompatibles de React, el store de pnpm materializa dos versiones y el grafo enlaza a cada paquete con la suya. No es una advertencia teórica: es un directorio que puedes inspeccionar hoy mismo en tu repo.

pnpm ls react --depth -1 -r     # lista react en cada paquete del workspace
# apps/web      react 18.2.0
# apps/admin    react 18.3.0
# packages/ui   react 18.2.1    <- tres versiones, tres realidades

Cada una de esas líneas es un paquete que, en runtime, hablará con un React distinto. Si apps/web importa un componente de packages/ui, cruzan la frontera entre dos copias, y es exactamente ahí donde nace el Invalid hook call que después pasarás una tarde persiguiendo con un depurador, buscando en el código de tu componente un bug que en realidad vive en tres líneas de tres package.json que nadie miró juntas.

El coste humano: upgrades y conflictos de merge

Aun cuando los rangos solapan y no hay bug de runtime, la dispersión cobra un impuesto en tiempo humano. Subir React de ^18.2.0 a ^18.3.1 significa abrir treinta package.json, editar treinta líneas y confiar en no olvidar ninguna —porque la que olvides reintroduce la deriva—. Cada uno de esos cambios toca un archivo que otra rama probablemente también tocó, y el resultado es una cosecha de conflictos de merge en package.json que no aportan información: nadie discrepa sobre qué versión de React quiere, simplemente el archivo se ha vuelto un punto de contención mecánico.

# el tamano del problema, medido:
grep -rl '"react"' --include=package.json . | wc -l   # cuantos archivos hay que tocar
# basta olvidar uno para reabrir la deriva que creias cerrada

El impuesto se agrava con la automatización mal calibrada. Un bot como Renovate o Dependabot, al ver la misma dependencia en treinta manifiestos, puede abrir treinta pull requests —o uno gigantesco— para un solo upgrade conceptual. La señal (“hay una versión nueva de React”) es una; el ruido (“edita estos treinta sitios”) se multiplica por la dispersión. Centralizar la versión colapsa ese ruido a su tamaño real: un cambio, un sitio, un PR.

Y hay un coste de segundo orden, más sutil: el ruido erosiona la atención. Cuando cada upgrade trivial produce un diff de treinta archivos, los revisores dejan de leerlos de verdad y aprueban en automático, y esa fatiga es justo la grieta por la que se cuela el cambio de versión que sí importaba. La dispersión no solo cuesta tiempo directo; degrada la calidad de la revisión de todo lo demás que viaja en el mismo pull request.

📝
El síntoma se ve lejos de la causa

Casi todo lo que hace tan cara la deriva es la distancia entre causa y efecto. La causa es aburrida y local —dos rangos en dos manifiestos— pero el efecto aparece lejísimos: un hook que peta en producción, un bundle que pesa de más, un conflicto de merge un viernes, un revisor que aprueba sin mirar. Los catalogs atacan la causa en su origen, y por eso desactivan de golpe una familia entera de síntomas que parecían inconexos.

Antes de los catalogs: detectar en vez de prevenir

La deriva no es un problema nuevo, y el ecosistema llevaba años parcheándolo por fuera. Herramientas como syncpack recorren todos los package.json y listan los rangos que no casan, ofreciendo alinearlos; convenciones de equipo piden “consulta la wiki antes de añadir una dependencia”. Funcionan, pero comparten una debilidad estructural: atacan el síntoma después de que aparezca. Corrigen la divergencia una vez ocurrida, dependen de correr en CI, y no impiden que se reintroduzca al minuto siguiente con el próximo pnpm add despistado.

# el enfoque clasico: detectar la divergencia a posteriori
pnpm dlx syncpack list-mismatches

Es la diferencia entre un detector de humos y un material ignífugo. El detector te avisa cuando ya hay fuego; el material hace que no pueda arder. Los catalogs aspiran a lo segundo: no a señalar la deriva, sino a eliminar la posibilidad de que exista.

flowchart TD
SIN[sin catalog tres rangos dispersos] --> DRIFT[deriva posible en cualquier commit]
CON[con catalog una entrada referenciada] --> ONE[una sola version garantizada]
style DRIFT fill:#eba0ac,color:#11111b
style ONE fill:#a6e3a1,color:#11111b

El salto conceptual es de gobernanza, no de herramienta: pasas de un mundo donde N archivos declaran versiones que pueden discrepar a uno donde una entrada declara la versión que todos heredan. La coherencia deja de ser un resultado que hay que lograr y vigilar, y pasa a ser la única forma que el sistema permite.

🐛

Bugs de runtime

Dos copias de un singleton (React, estado, validadores) rompen hooks, contextos e instanceof sin avisar al type checker.

📦

Peso de bundle

El bundler no puede deduplicar lo que resuelve a versiones distintas: el usuario descarga la misma librería dos veces.

🔁

Upgrades frágiles

Subir una versión obliga a editar N archivos; el que se te escape reabre la deriva que creías cerrada.

💥

Conflictos de merge

Muchos package.json tocados por muchas ramas convierten un cambio trivial en un campo de conflictos.

La versión de una dependencia es estado compartido, y el estado compartido necesita un dueño

Un monorepo es, en el fondo, un sistema distribuido de package.json, y la versión de cada dependencia común es una variable de estado replicada en todos ellos. Toda la ingeniería de software del último medio siglo dice lo mismo sobre el estado replicado sin una fuente de verdad: deriva, se corrompe y produce bugs que nadie sabe reproducir. La solución que aplicamos en el código —no repetir una constante mágica en veinte sitios, sino declararla una vez y referenciarla— es exactamente la que faltaba en la capa de dependencias. Durante años la parcheamos por fuera: linters que detectan la divergencia tras el hecho, o convenciones de equipo que dependen de que nadie se despiste. Los catalogs internalizan la idea en el package manager: elevan el rango de versión de dato disperso a constante nombrada, definida una vez en pnpm-workspace.yaml y referenciada con catalog: desde cada paquete. La deriva deja de ser algo que hay que detectar y corregir y pasa a ser algo que no puede ocurrir, porque ya no hay treinta fuentes que puedan discrepar: hay una. Y fíjate en que esto no es solo comodidad: es un cambio de categoría del problema. Detectar la deriva es una tarea recurrente que nunca termina, porque cada commit puede reintroducirla; eliminar la posibilidad de deriva es un acto de diseño que se hace una vez y rinde para siempre. Interiorizar esto es entender que la gestión de dependencias a escala no es un problema de instalación, sino un problema de diseño de la fuente de verdad —y que la respuesta correcta a “cómo evito que estos números diverjan” nunca fue vigilarlos mejor, sino hacer que haya un solo número que vigilar—.

⚔️ Mide tu propia deriva
  1. En un monorepo real, ejecuta pnpm why react (o tu dependencia más común) y cuenta cuántas versiones distintas resuelve el grafo.
  2. Abre cinco package.json de paquetes distintos y compara el rango con el que declaran una misma librería compartida: ¿coinciden al carácter?
  3. Revisa el git log de un package.json concreto y localiza un conflicto de merge pasado sobre una línea de dependencia.
  4. Estima el coste: si subieras hoy la versión de esa librería, ¿cuántos archivos tendrías que tocar y cuántos podrías olvidar?
  5. Escribe en una frase qué fuente de verdad de versiones tiene hoy tu repo. Si la respuesta es “ninguna”, ya sabes qué resuelven los catalogs.