wandres.dev
PUBLICAR LA LIBRERÍA · versiones y provenance

semver para librerías: el contrato del breaking change

semver no es un formato de tres números, sino un protocolo de compatibilidad entre tú y miles de package.json ajenos. Qué constituye de verdad un cambio incompatible más allá de la firma de una función, cómo la ley de Hyrum convierte cualquier comportamiento observable en contrato, el procedimiento exacto para decidir major, minor o patch, los breaking changes de tipos y de entorno, y por qué el coste asimétrico de un patch mentiroso obliga a subir alto en la duda.

⏱ 18 min

Publicar una versión es firmar un contrato. El número que eliges —2.4.1, 3.0.0— no describe tu código: describe una promesa de compatibilidad que miles de package.json ajenos van a creerse sin leer tu diff. semver no es un formato de tres cifras separadas por puntos, sino un protocolo de comunicación con un ecosistema que nunca verás, donde un solo dígito mal elegido rompe builds en producción a las tres de la madrugada. Entender qué es de verdad un cambio incompatible —y no lo que crees que es— separa al mantenedor del que solo sube código.

🎯 Al terminar esta lección sabrás
  • Definir con precisión qué constituye un breaking change, más allá de la firma de una función.
  • Aplicar un procedimiento de decisión inequívoco para elegir entre major, minor y patch.
  • Reconocer los cambios rompedores de tipos, de entorno y de comportamiento observable.
  • Interiorizar semver como contrato social y calibrar la confianza que cada rango deposita en ti.

Qué es de verdad un breaking change

La superficie de una librería no es lo que dice su documentación: es todo lo que un consumidor puede observar. Esa es la esencia de la ley de Hyrum: con suficientes usuarios, todo comportamiento observable de tu API acaba siendo dependido por alguien, lo prometieras o no. El orden en que emites eventos, el texto exacto de un mensaje de error, el hecho de que una función resultara ser síncrona, la forma del objeto que devuelves: alguien, en algún lugar, construyó encima de ello.

De ahí sale la definición formal, la única que importa: un cambio es rompedor si puede hacer que un programa correcto contra la versión N falle contra N+1 sin que el consumidor toque una sola línea. Todo lo demás se deriva de aquí. Las clases de rotura se ordenan solas:

  • Superficie: eliminar o renombrar un export, mover un símbolo a otro subpath, retirar una entrada del campo exports.
  • Firma: añadir un parámetro obligatorio, reordenarlos, estrechar los tipos que aceptas.
  • Comportamiento: cambiar un valor por defecto, la clase de error que lanzas, la forma del retorno, o pasar de síncrono a asíncrono.
  • Tipos: en 2026 los tipos son el contrato; endurecer una restricción genérica o renombrar una interface pública rompe el tsc del consumidor aunque el runtime sea idéntico byte a byte.
  • Entorno: retirar una major de Node de engines, eliminar la build CommonJS o migrar a ESM puro son roturas aunque tu código no cambie.
// PATCH: corrige un calculo sin tocar la superficie
export function total(xs: number[]): number // arregla un off-by-one interno

// MINOR: anade un parametro opcional, retrocompatible
export function total(xs: number[], opts?: { redondear?: boolean }): number

// MAJOR: el parametro pasa a obligatorio y cambia el tipo de retorno
export function total(xs: number[], modo: Modo): Total

El reverso es igual de decisivo, porque el miedo a romper paraliza tanto como la temeridad. Estos cambios no rompen el contrato y por tanto no exigen major:

  • Ampliar lo que aceptas: admitir un tipo más general en un parámetro es seguro por contravarianza; lo que pasaba el tipo antiguo sigue siendo válido.
  • Añadir un parámetro o una propiedad opcional a un objeto de opciones, siempre que el valor por defecto preserve el comportamiento previo.
  • Refactorizar lo interno: reescribir la implementación, mover ficheros no expuestos en exports, cambiar dependencias privadas.
  • Añadir un export, una sobrecarga o un campo al retorno que el consumidor existente puede ignorar sin consecuencia alguna.

El caso sutil, el que distingue al artesano, es que una corrección de bug puede ser rompedora. Si la gente dependía del comportamiento erróneo —Hyrum de nuevo—, arreglarlo los rompe. El juicio es fino: si el comportamiento viejo era claramente incorrecto y no documentado, es un patch; si era ampliamente aprovechado, quizá merezca una major o una opción de compatibilidad. No hay algoritmo que sustituya ese criterio.

⚠️
Los tipos rompen aunque el runtime no se inmute

El error más moderno es creer que “no cambié el JavaScript, así que es un patch”. En un ecosistema tipado, ampliar lo que aceptas es seguro por contravarianza, pero estrechar un parámetro, renombrar un tipo exportado o subir tu versión mínima de TypeScript hace fallar la compilación de quien te consume. Un .d.ts es API pública tan vinculante como una función. Herramientas como api-extractor o un test de tipos con expect-type detectan estas roturas antes de publicarlas, comparando la superficie de tipos entre versiones.

Decidir el salto: major, minor o patch

Con la definición clara, la decisión deja de ser intuición y se vuelve un procedimiento. Recorres una sola pregunta jerárquica y el resultado es determinista:

flowchart TD
A[cambio listo para publicar] --> B[rompe a un consumidor correcto]
B -->|si| MAJ[MAJOR nuevo contrato]
B -->|no| C[anade superficie nueva]
C -->|si| MIN[MINOR retrocompatible]
C -->|no| PAT[PATCH correccion interna]
style MAJ fill:#f38ba8,color:#11111b
style MIN fill:#a6e3a1,color:#11111b
style PAT fill:#89b4fa,color:#11111b

La primera pregunta manda sobre todas: si la respuesta es sí, ya es una major y las demás sobran. Solo cuando descartas la rotura te preguntas si añades superficie —entonces minor— y solo si tampoco añades nada, es un patch. El orden no es casual: la incompatibilidad domina porque es la única cara del contrato que el consumidor no puede absorber sin trabajo.

💥

MAJOR

Rompe el contrato. El consumidor debe leer notas de migración y actuar. Es el único salto que un rango ^ no cruza solo.

MINOR

Añade sin romper. Actualizar debería ser transparente. Todo ^ existente lo recogerá en la próxima instalación.

🔧

PATCH

Corrige sin añadir ni romper. Actualizar es casi obligatorio. Es el vehículo natural de los parches de seguridad.

La zona 0.x reescribe estas reglas: mientras el primer número sea cero, la especificación considera que la API no se estabilizó, y cada dígito puede romper. Ahí un ^0.3.1 no captura 0.4.0; el 0.x entero es territorio de major implícita. Salir a 1.0.0 no es una ceremonia estética: es la declaración pública de que aceptas la disciplina completa del contrato.

ℹ️
El parche de seguridad tensiona la regla

Hay un caso donde el procedimiento choca con la realidad: una corrección de seguridad que, para ser efectiva, introduce un cambio incompatible. En rigor sería una major, pero forzar a todo el ecosistema a una migración manual deja sin parche a quien no puede migrar a tiempo. La práctica común es publicar la corrección como patch en cada línea mayor viva —respaldándola con un aviso claro y una entrada en el aviso de seguridad— aunque altere levemente el comportamiento. Es una excepción consciente al contrato, no una licencia para saltárselo: se documenta, se comunica y se limita al mínimo imprescindible.

El contrato con tus usuarios

El registro no valida semver: nada te impide técnicamente disfrazar una rotura de patch. semver funciona porque es un punto Schelling, una convención que todos respetan porque a todos conviene que todos la respeten. El consumidor codifica su nivel de confianza en el operador de rango: un ^ dice “confío en que respetas semver hasta la próxima major”; un anclaje exacto dice “no confío en nada”. Tu número es la contrapartida de esa confianza.

Y aquí está la asimetría que lo gobierna todo. Un patch mentiroso —una rotura escondida en un 1.2.4— se cuela por cada rango ^ del planeta y detona en silencio, a la vez, en miles de pipelines que no pidieron ningún cambio. Una major excesivamente prudente solo ralentiza la adopción, porque nadie la recibe sin querer. Los costes no se parecen en nada: por eso, en la duda, subes alto. Es más barato una major de más que un patch de menos.

Esa asimetría tiene un corolario en cómo comunicas cada salto, porque el número no viaja solo:

  • Una major exige una guía de migración: qué cambió, por qué, y el camino paso a paso para adaptarse.
  • Una minor basta con un changelog que enumere lo añadido; nada obliga a actuar al consumidor.
  • Un patch necesita una sola línea, pero es el que más silenciosamente se propaga: si mintió, rompe sin aviso.
💡
La deprecación es un protocolo, no un aviso

No retires nada por sorpresa. El ciclo correcto es marcar con @deprecated en los tipos y emitir un aviso en tiempo de ejecución durante una o varias minors, y solo eliminar en la siguiente major. Para versiones ya publicadas con un fallo grave, npm deprecate marca un rango entero con un mensaje que verá quien lo instale. La deprecación convierte una rotura futura en una migración anunciada: el usuario se entera cuando aún puede planificar, no cuando el build ya está roto.

# marca un rango ya publicado como obsoleto con un aviso al instalar
npm deprecate @acme/ui@"<2.0.0" "la linea 1.x deja de recibir soporte; migra a 2.x"

El campo exports es la frontera legal del contrato: solo lo que expones ahí es API pública, y todo lo demás es interno que puedes refactorizar sin bump de major. Delimitar esa frontera con precisión es lo que te da libertad para evolucionar por dentro sin romper por fuera.

Y como decidir el número a mano no escala, el ecosistema mecaniza la traducción de intención a número con tres piezas que conviene distinguir:

  • changesets: cada pull request declara su intención —patch, minor o major— en un fichero versionado; al fusionar, la herramienta suma los cambios y calcula el salto agregado.
  • semantic-release: deriva el salto de los mensajes de commit convencionales —fix: es patch, feat: es minor, un BREAKING CHANGE: fuerza major— sin decisión humana en el momento de publicar.
  • diffing de superficie: api-extractor o un test de tipos comparan la API pública entre versiones y fallan el CI si aparece una rotura no declarada, antes de que salga.
# changesets: declaras la intencion, la maquina calcula el numero
npx changeset            # eliges patch, minor o major y describes el cambio
npx changeset version    # agrega los changesets y sube el numero
npx changeset publish    # publica la version resultante

La máquina traduce intención a número, pero la intención sigue siendo tuya: ninguna herramienta sabe mejor que quien escribió el cambio si rompe a alguien. Declararla con honestidad es la única parte que no se automatiza.

Detectar la rotura antes de publicarla

Como semver es un contrato no verificado por el registro, la disciplina madura no confía en la memoria: instrumenta la detección de roturas accidentales para que el CI las cace antes de que una versión inmutable las selle. Tres capas de defensa se complementan:

  • Diff de la superficie de tipos: extraer el .d.ts público y compararlo entre versiones convierte una rotura de compilación silenciosa en un fallo de build visible en el pull request.
  • Tests de contrato: casos que ejercitan la API pública tal como la usa un consumidor real, no los internos; si uno rompe, es que rompiste el contrato, no una implementación.
  • Ensayo aguas abajo: instalar la versión candidata en un proyecto consumidor de prueba —o correr sus tests contra tu prerelease— revela roturas que ninguna comprobación local ve.

Ese arsenal traslada la pregunta “¿esto rompe algo?” del juicio individual a una respuesta mecánica y reproducible. No sustituye al criterio —la ley de Hyrum garantiza que siempre habrá dependencias que no puedes prever—, pero elimina la clase entera de roturas que sí eran detectables y se colaron por descuido.

El número es una promesa que otros ejecutan sin leerla

La lección profunda de este nivel es que semver invierte el sentido de la responsabilidad. Cuando consumes, un rango es tu declaración de cuánta confianza depositas; cuando publicas, el número es la promesa que cientos de resolutores automáticos van a ejecutar sin que ningún humano la lea. Esa es la razón por la que la ley de Hyrum no es una curiosidad académica: en cuanto tu paquete tiene tracción, tu contrato real no es tu documentación sino la suma de todos los comportamientos observables de los que alguien depende, y romper cualquiera de ellos rompe a alguien. De ahí se derivan las tres disciplinas del mantenedor maduro. Primera: define la superficie con exports para saber exactamente qué prometes y qué es interno libre. Segunda: trata los tipos como contrato vinculante y verifica la superficie de tipos entre versiones con herramientas, porque una rotura de compilación es tan grave como una de runtime. Tercera, y la que más cuesta interiorizar: en la duda sube alto, porque el coste de una major de más es adopción lenta, y el de un patch de menos es una rotura silenciosa multiplicada por todo el ecosistema. Cuando dejas de ver el número como una etiqueta y empiezas a verlo como una interfaz que máquinas obedecen, publicas distinto: cada bump se vuelve una afirmación deliberada sobre la compatibilidad, no un incremento por costumbre. Esa es la diferencia entre mantener algo de lo que otros dependen y subir código al vacío.

⚔️ Clasifica cambios como el registro nunca lo hará
  1. Para cinco cambios reales de una librería tuya, decide major, minor o patch aplicando la pregunta jerárquica y justifica cada uno.
  2. Encuentra un caso donde una corrección de bug sea, por la ley de Hyrum, un breaking change, y razona qué harías.
  3. Cambia un tipo exportado sin tocar el JavaScript y comprueba con un test de tipos que rompes la compilación de un consumidor.
  4. Marca un export con @deprecated, emite un aviso en runtime y planifica en qué major lo eliminarías.
  5. Configura changesets en un paquete de práctica y deja que derive el número a partir de tu declaración de intención.