Apuntes · Capítulo 4Proceso y requisitos

Especificaciones, contratos y design docs

Especificaciones, contratos y design docs

La semana pasada trabajamos del lado del cliente: entrevistamos a Doris, separamos el problema de la solución que traía y escribimos historias de usuario con criterios de aceptación en la forma dado, cuando, entonces. Un criterio de aceptación dice qué tiene que observar el cliente para dar por cumplida una historia. Pero un sistema no es una sola pieza: es un montón de funciones y módulos que se llaman unos a otros, y cada una de esas llamadas también es un acuerdo. Cuando el handler de POST /api/bookings llama a overlaps, ¿qué tiene que garantizar el handler y qué le garantiza overlaps a cambio? Si nadie lo escribió, la única respuesta es leer el código, y entonces cada detalle del código pasa a ser parte del acuerdo.

Esta semana pasamos de los requisitos del cliente a las especificaciones de los módulos. En la primera parte vemos qué es una especificación, cómo se escribe como contrato (precondición, postcondición e invariante, en la forma que propuso Bertrand Meyer), quién es responsable de revisar cada condición y por qué una postcondición incompleta deja pasar implementaciones absurdas. Después usamos el sistema de tipos de TypeScript como una especificación que tsc revisa sola: uniones de literales, uniones discriminadas, parse, don’t validate, y también lo que los tipos no pueden decir.

En la segunda parte subimos de nivel: el design doc, el documento que se escribe antes de construir algo que no es trivial, para pensar el problema y recibir críticas cuando cambiar todavía es barato; la documentación tratada como código; y el desarrollo guiado por especificaciones con agentes de IA, donde la especificación pasa a ser la entrada principal del trabajo. Todo termina en el laboratorio: una especificación escrita por ustedes, una implementación a mano o con un agente, y las pruebas de aceptación de la administración para ver quién decidió qué donde la especificación callaba.

Lectura. Para la Sesión A: Bertrand Meyer, Applying “Design by Contract”, IEEE Computer, 1992; Alexis King, Parse, don’t validate (2019); y TypeScript Handbook, Narrowing (hasta Discriminated unions y Exhaustiveness checking). Complementaria: TypeScript Handbook, Everyday Types (uniones y tipos literales). Para la Sesión B: Malte Ubl, Design Docs at Google (2020); Software Engineering at Google (SWEG), cap. 10, Documentation; y Birgitta Böckeler, Understanding Spec-Driven-Development: Kiro, spec-kit, and Tessl (octubre de 2025). Complementaria: Stanford, CS146S The Modern Software Developer, la parte de desarrollo guiado por especificaciones con agentes. El código del capítulo está en code/week-04 y se prueba con make test dentro del contenedor del curso (./env/run.sh); el pedido y el template del laboratorio están en code/week-04/horas-posibles.md.

Ruta de lectura.

  • Antes de clase (obligatorio, unos 20 minutos): Qué es una especificación; de Contratos: precondición, postcondición e invariante, Diseño por contrato y Fuera de la precondición, no hay promesa; de Quién revisa: precondición fuerte o débil, Parcial y total; de Los tipos como especificación, Lo que revisa tsc y Uniones de literales en lugar de string; de Design docs, Qué es y para qué sirve y Las secciones; y de Desarrollo guiado por especificaciones, Qué es y Tres niveles.
  • En clase: Sesión A: Contratos: precondición, postcondición e invariante, Quién revisa: precondición fuerte o débil, Postcondiciones completas y Los tipos como especificación. Sesión B: Design docs, La documentación como código y Desarrollo guiado por especificaciones. Laboratorio: El laboratorio: de la especificación al código.
  • Después de clase: el Resumen y los Ejercicios. Para profundizar: Parse, don’t validate, Tipos marcados, Donde la especificación calla, el agente elige, Cómo se comprueba que el código cumple la especificación y las lecturas del recuadro Lectura.

Qué es una especificación

Qué, no cómo

Una especificación dice qué hace un módulo sin decir cómo lo hace. “Devuelve true si los dos bloques comparten al menos una hora” es una especificación de overlaps. “Compara a.startHour < b.startHour + b.hours y b.startHour < a.startHour + a.hours” es una implementación: una de las muchas que cumplen esa especificación. Otra sería armar el conjunto de horas de cada bloque y ver si tienen algo en común; otra, recorrer las horas de uno y preguntar por cada una si cae dentro del otro. Las tres cumplen la misma especificación, y quien llama a overlaps no necesita saber cuál se eligió.

Para eso sirve una especificación: es un acuerdo entre quien llama y quien implementa. Mientras los dos respeten el acuerdo, cualquiera de los dos puede cambiar su código sin avisarle al otro. El handler puede reorganizarse entero; overlaps puede reescribirse para que sea más rápida o más clara. Lo que no puede cambiar sin conversar es el acuerdo.

La palabra módulo aquí es amplia, como lo será la semana que viene: una función, una clase, un archivo, un endpoint HTTP, un servicio entero. Todos tienen una interfaz (lo que hay que saber para usarlos) y una implementación (lo que está detrás). La especificación describe la interfaz.

Sin especificación, el acuerdo es el código

¿Qué pasa si nadie escribe la especificación? Que el acuerdo sigue existiendo, pero es implícito: es todo lo que el código hace. En la semana 1 vimos la ley de Hyrum: con suficientes usuarios, todo comportamiento observable de un sistema termina siendo usado por alguien. Si GET /api/courts devuelve las canchas ordenadas por nombre y nadie dijo si ese orden es una promesa o una casualidad, algún cliente HTTP va a depender del orden, y el día que cambie, se rompe.

Una especificación escrita no elimina la ley de Hyrum, pero le pone un borde. Permite decir “eso nunca se prometió” y, sobre todo, permite que quien implementa sepa qué puede cambiar con tranquilidad y qué no. Sin especificación, la única forma de saber si un cambio es seguro es buscar a todos los que llaman y leer lo que hacen con el resultado.

Especificación, requisito y prueba

Tres cosas parecidas que conviene no confundir:

Habla de Para quién Ejemplo en Kallpa
Requisito El sistema visto desde afuera El cliente y el equipo “Una reserva se puede cancelar hasta 3 horas antes de que empiece”
Especificación Un módulo: qué recibe, qué garantiza Quien llama y quien implementa isCancellableAt(slot, now) devuelve true si y solo si faltan al menos 180 minutos para el inicio
Prueba Un caso concreto Quien cambia el código Con now el lunes a las 10:00, la reserva del lunes a las 14:00 se puede cancelar

Un requisito se reparte en varias especificaciones (la de isCancellableAt, la del endpoint de cancelación, la del mensaje de error). Una especificación se comprueba con muchas pruebas, pero ninguna cantidad de pruebas la reemplaza: las pruebas muestran casos; la especificación dice la regla.

Idea clave. Una especificación es un acuerdo que permite cambiar el código de cada lado sin preguntar al otro. Si no está escrito, el acuerdo es todo lo que el código hace, y entonces casi ningún cambio es seguro.

Contratos: precondición, postcondición e invariante

Diseño por contrato

Bertrand Meyer propuso, con el lenguaje Eiffel y en el artículo de la lectura, pensar cada rutina como un contrato entre dos partes, como un contrato entre personas: cada parte tiene obligaciones y, a cambio, beneficios. Su ejemplo es un servicio de mensajería: el cliente tiene la obligación de entregar un paquete que no pase de cierto peso y tamaño, y de pagar; a cambio, el paquete llega a destino en cuatro horas o menos. La empresa tiene la obligación de entregarlo; a cambio, no tiene que preocuparse de paquetes demasiado pesados ni de clientes que no pagaron. Lo que es obligación para uno es beneficio para el otro.

En software, el contrato de una rutina se escribe con tres clases de aserciones (condiciones que tienen que ser verdaderas en cierto momento):

  • La precondición es lo que quien llama tiene que garantizar antes de la llamada. Es su obligación, y el beneficio de la rutina.
  • La postcondición es lo que la rutina garantiza al terminar, siempre que se haya cumplido la precondición. Es su obligación, y el beneficio de quien llama.
  • El invariante es lo que vale siempre entre operaciones: antes y después de cada una.

Aplicado a overlaps de Kallpa (src/domain/booking.ts):

// Dos bloques se cruzan si cada uno empieza antes de que el otro termine.
export function overlaps(a: Pick<TimeSlot, 'startHour' | 'hours'>, b: Pick<TimeSlot, 'startHour' | 'hours'>): boolean {
  return a.startHour < b.startHour + b.hours && b.startHour < a.startHour + a.hours;
}
Obligación Beneficio
Quien llama Pasar dos bloques de la misma cancha y el mismo día Saber si se cruzan, sin pensar en cómo
overlaps Responder true si y solo si los bloques comparten al menos una hora No tener que mirar fechas ni canchas

El comentario de Kallpa dice cómo funciona (“cada uno empieza antes de que el otro termine”), que es útil para quien lee la implementación. El contrato dice qué promete, que es lo que necesita quien la llama.

La postcondición se puede comprobar

Una postcondición bien escrita se puede convertir en una prueba. “Responde true si y solo si los bloques comparten al menos una hora” tiene una traducción directa: para cada par de bloques, calcular de la forma más simple posible si comparten una hora y comparar con lo que dice overlaps. Como un día de Kallpa tiene pocos bloques posibles (15 de una hora, 14 de dos y 13 de tres), se pueden probar todos los pares (overlaps.test.ts):

// El oráculo, escrito de la forma más tonta posible: las horas de cada
// bloque como conjunto, y si los conjuntos tienen algo en común.
function sharesAnHour(a: Block, b: Block): boolean {
  for (let h = a.startHour; h < a.startHour + a.hours; h++) {
    if (h >= b.startHour && h < b.startHour + b.hours) return true;
  }
  return false;
}

test(`overlaps cumple su postcondición en los ${blocks.length * blocks.length} pares de bloques posibles`, () => {
  for (const a of blocks) {
    for (const b of blocks) {
      assert.equal(overlaps(a, b), sharesAnHour(a, b), `${JSON.stringify(a)} y ${JSON.stringify(b)}`);
    }
  }
});
$ node --test overlaps.test.ts
✔ overlaps cumple su postcondición en los 1764 pares de bloques posibles (1.95075ms)
✔ fuera de la precondición, la respuesta no significa nada (0.242417ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 83.016208

La función que hace de oráculo (la que dice cuál es la respuesta correcta) está escrita de otra forma que la implementación, a propósito. Si el oráculo fuera una copia de overlaps, la prueba compararía la función consigo misma y pasaría siempre. En la semana 10 veremos cómo generalizar esta idea con pruebas basadas en propiedades, cuando los casos posibles son demasiados para probarlos todos.

Fuera de la precondición, no hay promesa

¿Qué pasa si quien llama no cumple la precondición? Meyer es tajante: si la precondición no se cumple, la rutina no está obligada a nada, como la empresa de mensajería a la que le entregan un paquete que no cumple las condiciones. Puede devolver cualquier cosa, lanzar un error o colgarse. No está obligada a lanzar un error: el contrato no dice nada para ese caso.

overlaps no recibe la fecha, así que no puede saber si los bloques son del mismo día. Si alguien le pasa un bloque del martes y otro del miércoles, responde como si fueran del mismo día:

test('fuera de la precondición, la respuesta no significa nada', () => {
  // Martes de 18:00 a 20:00 y miércoles de 19:00 a 20:00: no comparten nada,
  // pero overlaps no recibe la fecha y responde true. No es un bug de
  // overlaps: quien la llamó no cumplió su parte.
  const tuesday = { date: '2026-03-10', startHour: 18, hours: 2 };
  const wednesday = { date: '2026-03-11', startHour: 19, hours: 1 };
  assert.equal(overlaps(tuesday, wednesday), true);
});

Meyer saca de aquí una regla para repartir la culpa: una violación de la precondición es un bug de quien llama; una violación de la postcondición es un bug de la rutina. Esa regla es la que permite decidir, cuando algo falla, a quién le toca arreglarlo. En Kallpa, el handler de createBooking filtra las reservas por cancha y día antes de llamar a overlaps (bookingsForCourt(court.id, request.date)): cumple la precondición.

A la izquierda, quien llama: createBooking, que filtra antes de llamar con bookingsForCourt(court.id, request.date). A la derecha, la rutina: overlaps(a, b), que solo recibe startHour y hours y no puede ver ni la fecha ni la cancha. De izquierda a derecha, una flecha pasa por la precondición: dos bloques de la misma cancha y del mismo día. De derecha a izquierda, otra pasa por la postcondición: true si y solo si los bloques comparten al menos una hora. Debajo, dos frases: si se rompe la precondición, el bug es de quien llama; si se rompe la postcondición, el bug es de la rutina. Al final, el caso fuera de la precondición: overlaps con el martes de 18:00 a 20:00 y el miércoles de 19:00 a 20:00 responde true, y esa respuesta no significa nadaA la izquierda, quien llama: createBooking, que filtra antes de llamar con bookingsForCourt(court.id, request.date). A la derecha, la rutina: overlaps(a, b), que solo recibe startHour y hours y no puede ver ni la fecha ni la cancha. De izquierda a derecha, una flecha pasa por la precondición: dos bloques de la misma cancha y del mismo día. De derecha a izquierda, otra pasa por la postcondición: true si y solo si los bloques comparten al menos una hora. Debajo, dos frases: si se rompe la precondición, el bug es de quien llama; si se rompe la postcondición, el bug es de la rutina. Al final, el caso fuera de la precondición: overlaps con el martes de 18:00 a 20:00 y el miércoles de 19:00 a 20:00 responde true, y esa respuesta no significa nada
El contrato rodea la llamada: quien llama responde por lo que entra y la rutina, por lo que sale. Por eso, cuando algo falla, se sabe a quién le toca arreglarlo.

Error común. Pensar que una función “debería funcionar con cualquier entrada” y llamar bug a lo que hace fuera de su precondición. Si la precondición está escrita, el bug está en quien la llamó mal. Si no está escrita, el bug está en la especificación, que no dijo qué se esperaba.

Invariantes: de una clase y de un sistema

Meyer define el invariante de clase como una propiedad que vale para todos los objetos de una clase en sus estados observables: se cumple apenas se crea el objeto, y cada operación pública que la encuentra cumplida la deja cumplida al terminar. Su ejemplo es un árbol binario: el padre del hijo izquierdo de un nodo es el propio nodo. Durante una operación el invariante puede romperse por un momento (a mitad de una inserción, por ejemplo); lo que importa es que vuelva a valer antes de que cualquier otro pueda mirar. En la práctica, el invariante se agrega a la precondición y a la postcondición de cada operación pública.

En este curso usamos la misma idea a la escala de un sistema, que es una extensión del concepto de Meyer y no lo que él define. Kallpa tiene este invariante:

Una cancha no tiene dos reservas confirmadas que se crucen (mismo courtId, misma date y bloques que comparten una hora).

Es la regla más importante del complejo, y ninguna función la tiene como postcondición sola: la mantiene la secuencia “filtrar las reservas confirmadas de esa cancha y ese día, revisar overlaps, y solo si no hay cruce, insertar” dentro de createBooking. Cada operación que cambia las reservas tiene que dejar el invariante cumplido si lo encontró cumplido: crear una reserva lo puede romper, así que revisa; cancelar no lo puede romper, porque solo quita una reserva confirmada.

¿Quién más lo protege? Nadie. La tabla bookings de SQLite no tiene ninguna restricción que impida dos filas cruzadas (invariant.test.ts inserta una fila directamente con el repositorio, sin pasar por el handler):

test('la base de datos acepta una fila que lo rompe: nadie más lo revisa', () => {
  const db = openDatabase(':memory:');
  seedDatabase(db, TODAY);
  const repo = new BookingRepository(db);
  // Real Barranco tiene la cancha 1 hoy de 19:00 a 21:00. Una inserción que
  // no pasa por el handler mete otra de 20:00 a 21:00.
  repo.insert({ court_id: 1, member_id: 3, date: TODAY, start_hour: 20, hours: 1, /* ... */ });
  const pairs = overlappingPairs(allBookings(repo, addDays(TODAY, -3), 10));
  assert.equal(pairs.length, 1);
  // ...
});
$ node --test invariant.test.ts
✔ los datos de ejemplo cumplen el invariante (2.083917ms)
✔ la base de datos acepta una fila que lo rompe: nadie más lo revisa (0.950916ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 113.77325

overlappingPairs (invariant.ts) no hace cumplir el invariante: dice si se cumple. Es útil en pruebas y para revisar los datos de producción de vez en cuando. Que el invariante dependa de que todo el código pase por un solo camino es una decisión de diseño con consecuencias: en la semana 7 veremos qué pasa con dos instancias del servidor sobre el mismo archivo, y qué opciones hay (una sola instancia, o una restricción en la base de datos).

Idea clave. Precondición: lo que garantiza quien llama. Postcondición: lo que garantiza la rutina si se cumplió la precondición. Invariante: lo que vale entre operaciones, y que cada operación tiene que dejar como lo encontró. Si se rompe la precondición, el bug es de quien llama; si se rompe la postcondición, de la rutina.

Quién revisa: precondición fuerte o débil

Parcial y total

Toda condición sobre la entrada tiene que quedar a cargo de alguien. Hay dos formas extremas de repartirla:

  • Precondición fuerte. La rutina exige la condición y no la revisa. Quien llama la garantiza. La rutina es parcial: Meyer la describe como una rutina que solo funciona si quien llama asegura ciertas condiciones.
  • Precondición débil. La rutina revisa la condición y, si no se cumple, responde algo definido: un valor de error, una excepción documentada. Si la precondición es simplemente true (cualquier entrada vale), la rutina es total: tiene un comportamiento especificado para toda entrada. La palabra total viene de las matemáticas (una función total está definida en todo su dominio); Meyer usa partial y habla de estilos demanding (exigente) y tolerant (tolerante).

checkBookingRules de Kallpa es total. Para cualquier bloque, de cualquier forma, devuelve la primera regla que no se cumple o null:

// Revisa las reglas que no dependen de otras reservas. Devuelve la primera
// regla que no se cumple, o null si la reserva se puede hacer.
export function checkBookingRules(slot: TimeSlot, now: LimaTime): BookingRuleViolation | null {

overlaps, en cambio, es parcial: exige que los bloques sean del mismo día y la misma cancha, y no lo revisa (no podría: no recibe esos datos).

Ninguna de las dos opciones es la correcta en general. Meyer dice que elegir entre ellas es, en parte, cuestión de preferencia, y que el criterio es que la arquitectura completa quede lo más simple posible. Lo que sí es obligatorio es que la especificación diga cuál se eligió.

Tres contratos para formatSoles

formatSoles de Kallpa (src/domain/pricing.ts) convierte céntimos en el texto que ve el vecino:

// 11250 -> 'S/ 112.50'
export function formatSoles(cents: number): string {
  return `S/ ${(cents / 100).toFixed(2)}`;
}

El comentario da un ejemplo, no un contrato. ¿Qué pasa con un monto negativo, o con medio céntimo? Esto responde hoy:

$ node -e "import('../../kallpa/src/domain/pricing.ts').then(({ formatSoles }) => { for (const c of [11250, -50, 12.5, 0.5, NaN]) console.log(c, formatSoles(c)); })"
11250 S/ 112.50
-50 S/ -0.50
12.5 S/ 0.13
0.5 S/ 0.01
NaN S/ NaN

Ninguno de esos resultados se ve como un error. S/ 0.13 para 12.5 céntimos parece un precio razonable, y es el síntoma de un bug en otro lado: en Kallpa todo el dinero es un entero de céntimos, así que un 12.5 quiere decir que alguien hizo una cuenta con decimales. ¿Le corresponde a formatSoles detectarlo? Depende del contrato. En soles.ts hay tres versiones:

// 1. Parcial (precondición fuerte): quien llama garantiza que `cents` es un
// entero mayor o igual que cero. La función no lo revisa; si no se cumple,
// no promete nada.
export function formatSolesPartial(cents: number): string {
  return formatSoles(cents);
}

// 2. Precondición débil, error con una excepción: para cualquier número
// responde, pero la mitad de la respuesta (el RangeError) no aparece en el tipo.
export function formatSolesChecked(cents: number): string {
  if (!Number.isSafeInteger(cents) || cents < 0) {
    throw new RangeError(`Se esperaba un entero de céntimos >= 0 y llegó ${cents}.`);
  }
  return formatSoles(cents);
}

// 3. Total, con el error en el tipo: quien llama no puede usar el texto sin
// haber mirado antes `ok`.
export type FormatResult = { ok: true; text: string } | { ok: false; code: 'NOT_AN_INTEGER' | 'NEGATIVE' };

export function formatSolesResult(cents: number): FormatResult {
  if (!Number.isSafeInteger(cents)) return { ok: false, code: 'NOT_AN_INTEGER' };
  if (cents < 0) return { ok: false, code: 'NEGATIVE' };
  return { ok: true, text: formatSoles(cents) };
}

La primera tiene el mismo código que la original; lo que cambia es que ahora el contrato está escrito, y con él la respuesta a “¿qué pasa con -50?”: no es asunto de esta función. La segunda y la tercera tienen precondición débil; se diferencian en cómo avisan. La excepción es parte del contrato (está documentada), pero el tipo string no la muestra y quien llama puede olvidarla. El resultado con ok obliga a mirarlo, como veremos en la sección de tipos. ¿Cuál conviene? Para un formateador que se llama desde muchos lugares con montos que salen de la base de datos, la parcial con el contrato escrito es razonable: el error de verdad está antes, en quien produjo un monto con decimales. Si el monto viniera de un formulario, la frontera sería otra y habría que revisar ahí (lo veremos con parse, don’t validate).

Lo que dice Meyer sobre revisar dos veces

La programación defensiva aconseja revisar todo en todas partes, “por si acaso”: si no ayuda, al menos no hace daño. Meyer sostiene lo contrario: los chequeos redundantes agregan código, el código agrega complejidad, y la complejidad es el peor enemigo de la confiabilidad. Su regla es absoluta para una rutina: una condición va en la precondición o en un if del cuerpo de la rutina, nunca en los dos. Y la extiende al reparto entre las partes: con un contrato preciso, cada condición tiene un solo responsable, y no hacen falta chequeos repetidos.

Dos aclaraciones del propio artículo evitan malentendidos. Primera: que una rutina tenga una precondición no quiere decir que cada llamada tenga que hacer un if antes; quien llama tiene que garantizarla, y a veces ya la garantiza el contexto (en Kallpa, bookingsForCourt ya devuelve solo las reservas de esa cancha y ese día). Segunda: si todos los que llaman tienen que hacer el mismo tratamiento especial cuando la condición no se cumple, el contrato es demasiado exigente y conviene cambiarlo para que la rutina se encargue.

¿Y Kallpa? La cantidad de horas se revisa en dos lugares. En el handler, parseBookingRequest exige que hours sea un entero:

function requireInteger(fields: Record<string, unknown>, name: string): number {
  const value = fields[name];
  if (typeof value !== 'number' || !Number.isInteger(value)) {
    throw new HttpError(400, 'VALIDATION_ERROR', `El campo ${name} debe ser un número entero.`);
  }
  return value;
}

Y después checkBookingRules vuelve a preguntar si es entero:

  if (!Number.isInteger(slot.hours) || slot.hours < 1) {
    return { code: 'INVALID_HOURS', message: 'La cantidad de horas debe ser un entero mayor que cero.' };
  }

¿Es la redundancia que critica Meyer? No necesariamente. Son dos rutinas con dos contratos distintos: parseBookingRequest revisa la forma del cuerpo que llega por HTTP, y checkBookingRules es una función de dominio total que cualquiera podría llamar con cualquier bloque, no solo el handler. Que las dos revisen “entero” es una consecuencia de que cada una cumple su contrato. Lo que falta es que esos contratos estén escritos: hoy, alguien que lea las dos líneas puede borrar una “porque ya se revisa en el otro lado”, y otra persona puede borrar la otra por la misma razón.

Error común. Revisar la misma condición en tres capas “por si acaso” (la página, el handler y el dominio), o en ninguna, porque cada capa cree que la revisa otra. Las dos cosas pasan por lo mismo: nadie escribió quién es responsable de cada condición. La solución no es “revisar en todas partes” ni “en ninguna”, sino decidir y escribirlo en el contrato.

En la industria. La revisión de precondiciones en tiempo de ejecución es común en bibliotecas estándar. En Java, Objects.requireNonNull convierte “el argumento no es null” en una precondición revisada con un error claro. En Node, muchas funciones de la biblioteca estándar lanzan un error con el código ERR_INVALID_ARG_TYPE cuando reciben un argumento del tipo equivocado. Es el estilo tolerante en la frontera de una biblioteca que usan miles de personas: el autor no puede confiar en que todas lean la documentación, y un error temprano y claro ahorra horas de búsqueda. Dentro de un mismo equipo, con contratos escritos y revisados, el estilo exigente es más frecuente.

Postcondiciones completas

Una función que siempre devuelve []

Una especificación como “devuelve las horas libres de una cancha en un día” parece clara. Ahora miren esta función:

// Cumple "devuelve horas libres": todo lo que devuelve está libre.
export function freeHoursNone(_bookings: readonly Booking[]): number[] {
  return [];
}

¿Cumple la especificación? Literalmente, sí: cada hora que devuelve (ninguna) está libre. Y esta otra también:

// También: la primera hora libre está libre.
export function freeHoursFirstOnly(bookings: readonly Booking[]): number[] {
  return freeHours(bookings).slice(0, 1);
}

La frase solo dice que lo devuelto es correcto. No dice que esté todo. Una postcondición completa tiene que cerrar las dos puertas (free-hours.ts):

// Precondición: `bookings` son las reservas de una sola cancha y un solo día.
// Postcondición: devuelve TODAS las horas h entre la apertura y la última
// hora de inicio tales que ninguna reserva confirmada ocupa h, y SOLO esas,
// sin repetir y de menor a mayor.
export function freeHours(bookings: readonly Booking[]): number[] {

“Todas” excluye freeHoursNone y freeHoursFirstOnly. “Y solo esas” excluye una función que devuelve las horas de 7 a 21 sin mirar las reservas. “Sin repetir y de menor a mayor” excluye una que devuelve las horas en otro orden: la página las muestra en el orden en que llegan, y una lista desordenada sería un bug para el vecino aunque todas las horas fueran correctas.

Lo mismo pasa en las pruebas

Una prueba que solo verifica que lo devuelto es correcto tiene el mismo hueco que la especificación incompleta. En free-hours.test.ts, el martes tiene a Real Barranco de 18:00 a 20:00 y una reserva cancelada a las 9:00. La prueba débil revisa que ninguna hora devuelta sea las 18 o las 19; la completa compara contra la lista entera, calculada de otra forma:

$ node --test free-hours.test.ts
▶ la prueba débil: "todo lo que devuelve está libre"
  ✔ freeHours la pasa (0.47625ms)
  ✔ freeHoursNone la pasa (0.089959ms)
  ✔ freeHoursFirstOnly la pasa (0.083667ms)
✔ la prueba débil: "todo lo que devuelve está libre" (2.269708ms)
▶ la postcondición completa
  ✔ freeHours la cumple (0.636375ms)
  ✔ freeHoursNone no: le faltan las 13 horas libres (0.117292ms)
  ✔ freeHoursFirstOnly no: le faltan 12 (0.079291ms)
  ✔ el revisor también ve horas repetidas o desordenadas (0.081167ms)
✔ la postcondición completa (1.139083ms)
ℹ tests 7
ℹ suites 2
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 90.317459

Las tres implementaciones pasan la prueba débil. Solo una cumple la postcondición completa: con la reserva cancelada, el martes tiene 13 horas libres (de 7 a 17, más las 20 y las 21), y la versión que devuelve [] no tiene ninguna.

Una fila con las horas de 7 a 21 del martes: las 18 y las 19 están ocupadas por Real Barranco, las 9 tienen una reserva cancelada y cuentan como libres. Debajo, cuatro implementaciones, con las horas que devuelve cada una marcadas, y dos columnas con el resultado de la prueba débil y de la postcondición completa. freeHours devuelve las 13 horas libres y pasa las dos. freeHoursNone no devuelve ninguna, le faltan las 13: pasa la débil y falla la completa. freeHoursFirstOnly devuelve solo las 7, le faltan 12: pasa la débil y falla la completa. Una función que devuelve de 7 a 21 sin mirar las reservas incluye las 18 y las 19, ocupadas, y falla las dosUna fila con las horas de 7 a 21 del martes: las 18 y las 19 están ocupadas por Real Barranco, las 9 tienen una reserva cancelada y cuentan como libres. Debajo, cuatro implementaciones, con las horas que devuelve cada una marcadas, y dos columnas con el resultado de la prueba débil y de la postcondición completa. freeHours devuelve las 13 horas libres y pasa las dos. freeHoursNone no devuelve ninguna, le faltan las 13: pasa la débil y falla la completa. freeHoursFirstOnly devuelve solo las 7, le faltan 12: pasa la débil y falla la completa. Una función que devuelve de 7 a 21 sin mirar las reservas incluye las 18 y las 19, ocupadas, y falla las dos
La prueba débil solo mira lo que se devolvió, y por eso la pasan tres implementaciones. Lo que falta solo se ve al comparar la lista entera.

Error común. Probar solo que “lo que devuelve está bien”: for (const h of result) assert.ok(isFree(h)). Esa prueba pasa con []. Toda función que devuelve una colección necesita al menos una prueba que compare la colección entera, o que revise también lo que falta.

Una lista para revisar una postcondición

Cuando escriban la postcondición de una función, pregúntense:

  1. ¿Todo lo que devuelve cumple? (corrección: ninguna hora ocupada).
  2. ¿Devuelve todo lo que cumple? (completitud: ninguna hora libre falta).
  3. ¿En qué orden?, ¿con repetidos o sin repetidos?
  4. ¿Qué pasa en los bordes? (la primera y la última hora del día, una reserva que termina justo cuando empieza otra, el día vacío, el día lleno).
  5. ¿Qué no cambia? Si la función recibe una lista, ¿la deja como estaba? Una función que ordena la lista que le pasaron en lugar de una copia cambia algo que quien llama no esperaba.
  6. ¿Una implementación tonta la cumple? Si return [], return null o “devolver siempre lo mismo” cumplen la postcondición, está incompleta.

La sexta pregunta es la más útil. Imaginen la implementación más perezosa posible y pregúntense si cumple lo que escribieron. Si la respuesta es sí, la especificación necesita otra frase.

Los tipos como especificación

Lo que revisa tsc

Hasta aquí, las especificaciones son texto en comentarios y documentos. Nadie las revisa salvo las personas y las pruebas. El sistema de tipos de TypeScript es una especificación parcial que sí se revisa sola: cuando una función dice (cents: number): string, tsc comprueba en cada llamada que se le pase un número y que el resultado se use como texto. En Kallpa, make typecheck corre tsc -p . con strict: true.

Dos cosas que conviene tener claras desde el principio:

  • Los tipos se borran. Node ejecuta los archivos .ts quitando los tipos antes de correrlos; no queda nada de ellos en tiempo de ejecución. Un tipo es una afirmación sobre el código que tsc revisa antes, no un control que corre después.
  • Los tipos solo saben lo que se les dice. Si un valor entra al programa como unknown (un JSON que llegó por HTTP) y alguien le pone un tipo sin revisarlo, tsc lo cree. Volveremos a esto con as.

Uniones de literales en lugar de string

Una reserva de Kallpa está confirmada o cancelada; no hay otro estado. El tipo dice exactamente eso (src/domain/booking.ts):

export const BOOKING_STATUSES = ['confirmed', 'cancelled'] as const;

export type BookingStatus = (typeof BOOKING_STATUSES)[number];

as const hace que el arreglo sea de solo lectura y que sus elementos tengan el tipo literal ('confirmed', no string). (typeof BOOKING_STATUSES)[number] es “el tipo de cualquier elemento del arreglo”: 'confirmed' | 'cancelled'. Así se tiene, con una sola fuente, una lista que existe en tiempo de ejecución (para recorrerla o para validar) y un tipo que existe para tsc.

¿Qué se gana frente a status: string? Que b.status === 'canceled' (con una sola l, la ortografía estadounidense) es un error de compilación en lugar de una condición que nunca es verdadera (bad/typo-status.ts):

bad/typo-status.ts(5,10): error TS2367: This comparison appears to be unintentional because the types '"confirmed" | "cancelled"' and '"canceled"' have no overlap.

Además, un switch sobre el estado puede ser exhaustivo, y quien lee el tipo Booking sabe, sin buscar, cuáles son los estados posibles.

Por qué no enum

En otros lenguajes, y en mucho código TypeScript, esto se escribiría con un enum. En el curso no se usa: un enum no es solo un tipo, genera código (un objeto con las claves y los valores), y Node, que solo quita los tipos, no sabe generarlo. Con la opción erasableSyntaxOnly del tsconfig, tsc lo avisa antes (bad/enum.ts falla a propósito):

$ tsc -p bad
...
bad/enum.ts(3,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
...

Y si se intenta ejecutar igual:

$ node bad/enum.ts
node:internal/modules/run_main:111
    triggerUncaughtException(
    ^

file:///work/code/week-04/bad/enum.ts:3
    // genera código, y Node solo quita los tipos.
  > enum Status {
      Confirmed,
      Cancelled,
  > }

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

Lo mismo vale para namespace y para los parámetros de constructor con private o public. El reemplazo de enum es lo que ya usa Kallpa: un arreglo u objeto as const y una unión de literales derivada de él.

Narrowing: cómo tsc aprende en cada rama

El capítulo Narrowing del Handbook describe cómo tsc refina el tipo de una variable según el flujo del programa. Si value es unknown y el código pregunta typeof value === 'number', dentro de ese if el tipo de value es number. Las formas más comunes:

Forma Ejemplo Dentro de la rama, el tipo es
typeof typeof value === 'string' string
Veracidad if (booking) con booking: Booking | undefined Booking
Igualdad value === 1 || value === 2 1 | 2
in 'booking' in result las variantes que tienen booking
instanceof error instanceof HttpError HttpError
Predicado de tipo isValidDate(value), con value is string string

El análisis es por flujo de control: tsc sigue los return, los throw y las asignaciones. Después de if (typeof x !== 'number') throw ..., en el resto de la función x es number. parseBookingRequest de Kallpa usa esto en su primera línea:

  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
    throw new HttpError(400, 'VALIDATION_ERROR', 'El cuerpo debe ser un objeto JSON.');
  }
  const fields = body as Record<string, unknown>;

Después del throw, tsc sabe que body es un objeto que no es null. El as Record<string, unknown> de la línea siguiente dice algo más (que se puede leer con cualquier clave), y está justificado por el if de arriba; volveremos a los as en un momento.

Uniones discriminadas: el resultado que puede fallar

Una unión discriminada es una unión de tipos objeto que comparten un campo con un valor literal distinto en cada variante: el discriminante. Es la forma natural de escribir en el tipo “esto puede salir bien o mal, y si sale mal, de estas maneras” (booking-result.ts):

export type BookResult =
  | { ok: true; booking: Booking }
  | { ok: false; code: 'SLOT_TAKEN' }
  | { ok: false; code: 'OUTSIDE_OPENING_HOURS'; open: number; close: number };

ok separa el éxito del fracaso, y code separa los fracasos entre sí. Cada variante lleva solo los datos que tienen sentido para ella: el éxito tiene la reserva; OUTSIDE_OPENING_HOURS tiene el horario para armar el mensaje.

La ventaja es que tsc no deja leer booking sin haber mirado antes ok (bad/no-narrowing.ts):

export function teamName(result: BookResult): string {
  return result.booking.teamName;
}
bad/no-narrowing.ts(5,17): error TS2339: Property 'booking' does not exist on type 'BookResult'.
  Property 'booking' does not exist on type '{ ok: false; code: "SLOT_TAKEN"; }'.

Con una excepción, en cambio, el tipo de retorno sería Booking y nada obligaría a quien llama a pensar en el caso de error. Por eso se dice que la unión discriminada lleva el error en el tipo.

switch exhaustivo con never

Al manejar un resultado, cada variante necesita su respuesta:

// Si se llega aquí con un valor, es que falta un caso en el switch. Con el
// tipo `never`, tsc lo avisa al compilar en lugar de esperar a producción.
export function assertNever(value: never): never {
  throw new Error(`Caso no previsto: ${JSON.stringify(value)}`);
}

export function httpStatus(result: BookResult): number {
  if (result.ok) return 200;
  switch (result.code) {
    case 'SLOT_TAKEN':
      return 409;
    case 'OUTSIDE_OPENING_HOURS':
      return 422;
    default:
      return assertNever(result);
  }
}

never es el tipo que no tiene ningún valor. Después del if (result.ok) y de los dos case, tsc ya descartó todas las variantes, así que en el default el tipo de result es never, y se puede pasar a assertNever. Si mañana alguien agrega una variante a la unión y se olvida de este switch, en el default el tipo ya no es never y la llamada no compila (bad/missing-case.ts agrega TOO_FAR_AHEAD):

bad/missing-case.ts(20,26): error TS2345: Argument of type '{ ok: false; code: "TOO_FAR_AHEAD"; maxDays: number; }' is not assignable to parameter of type 'never'.

El error aparece en el lugar exacto que falta actualizar, no en producción cuando llega el primer TOO_FAR_AHEAD. En un sistema con diez switch sobre el mismo resultado, eso es la diferencia entre una búsqueda y una lista de errores de compilación.

El Handbook muestra la misma idea sin función auxiliar: en el default, const _exhaustiveCheck: never = shape; return _exhaustiveCheck;. Para tsc las dos formas son equivalentes. La diferencia está en tiempo de ejecución: si a pesar de todo llega un valor que el tipo no contemplaba (por ejemplo, un code nuevo que vino de la base de datos a través de un as), la forma del Handbook lo devuelve como si nada, y assertNever lanza un error con el valor. Además, una sola función sirve para todos los switch del sistema.

Idea clave. Una unión de literales dice qué valores existen. Una unión discriminada dice qué formas puede tener un resultado y obliga a mirar cuál es antes de usarlo. Un switch con never en el default convierte “agregamos un caso y nos olvidamos de manejarlo” en un error de compilación.

Parse, don’t validate

Alexis King resume una forma de usar los tipos en una frase: parsear, no validar. Las dos operaciones revisan lo mismo; la diferencia está en lo que devuelven:

  • Validar responde sí o no, y se olvida de lo que comprobó. El resto del programa sigue teniendo un valor con el tipo de antes, y o bien confía en que alguien validó, o bien vuelve a validar.
  • Parsear convierte un valor con un tipo poco preciso en uno con un tipo más preciso, o falla. Lo que se comprobó queda escrito en el tipo del resultado, y el resto del programa ya no tiene que volver a revisarlo.

En hours.ts:

export type BookingHours = 1 | 2 | 3;

// Validar: responde sí o no y se olvida de lo que comprobó. Después del if,
// el valor sigue siendo `unknown` para tsc: el typeof de adentro solo vale
// dentro de esta función.
export function isValidHours(value: unknown): boolean {
  return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MAX_HOURS_PER_BOOKING;
}

// Parsear: si el valor sirve, lo devuelve con un tipo más preciso; si no,
// lanza un error. Lo comprobado queda escrito en el tipo del resultado.
export function parseHours(value: unknown): BookingHours {
  if (value === 1 || value === 2 || value === 3) return value;
  throw new RangeError(`La cantidad de horas debe ser 1, 2 o 3 y llegó ${JSON.stringify(value)}.`);
}

// Solo acepta horas ya parseadas: quien la llama no puede olvidarse de revisar.
export function endHour(startHour: number, hours: BookingHours): number {
  return startHour + hours;
}

Con la validación, tsc no aprende nada: dentro del if el valor sigue siendo unknown (bad/hours.ts):

if (isValidHours(hours)) {
  endHour(19, hours);
}
endHour(19, parseHours(hours));
bad/hours.ts(8,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'BookingHours'.

La línea 8 (dentro del if) no compila; la 10, con el valor parseado, sí. isValidHours podría devolver un predicado de tipo (value is BookingHours) y entonces la rama sí se refinaría; pero solo dentro de esa rama, y cualquier función que reciba el valor después tendría que recibirlo como BookingHours para conservar lo aprendido. Ese es el punto de King: la garantía tiene que viajar con el valor.

¿Dónde se parsea? King lo dice así: empujen la carga de la prueba lo más arriba posible, idealmente en la frontera del sistema, antes de actuar sobre los datos. En Kallpa, la frontera es el handler HTTP: el cuerpo llega como unknown (lo que devuelve readJson) y parseBookingRequest lo convierte en un BookingRequest. Desde ahí, el resto del código recibe request.startHour como number y no vuelve a preguntar si existe.

Tres filas de izquierda a derecha. Validar: hours, de tipo unknown, entra a isValidHours(hours), que responde boolean; hours sigue siendo unknown y endHour(19, hours) no compila, con el error TS2345. Parsear: hours, de tipo unknown, entra a parseHours(hours), que revisa o lanza; sale un BookingHours, que es 1, 2 o 3, y endHour compila; si no es 1, 2 ni 3, sale un RangeError. En Kallpa: body, de tipo unknown, lo que devuelve readJson, entra a parseBookingRequest, que revisa la forma; una línea punteada marca ahí la frontera; a un lado queda afuera, al otro queda adentro un BookingRequest y el resto del código, que no vuelve a preguntar. Si la forma está mal, sale un 400 VALIDATION_ERRORTres filas de izquierda a derecha. Validar: hours, de tipo unknown, entra a isValidHours(hours), que responde boolean; hours sigue siendo unknown y endHour(19, hours) no compila, con el error TS2345. Parsear: hours, de tipo unknown, entra a parseHours(hours), que revisa o lanza; sale un BookingHours, que es 1, 2 o 3, y endHour compila; si no es 1, 2 ni 3, sale un RangeError. En Kallpa: body, de tipo unknown, lo que devuelve readJson, entra a parseBookingRequest, que revisa la forma; una línea punteada marca ahí la frontera; a un lado queda afuera, al otro queda adentro un BookingRequest y el resto del código, que no vuelve a preguntar. Si la forma está mal, sale un 400 VALIDATION_ERROR
Las dos funciones revisan lo mismo. La diferencia es lo que queda después: al parsear, lo comprobado está escrito en el tipo y viaja con el valor.

Es un parse, don’t validate parcial, y conviene ver por qué. El tipo BookingRequest dice hours: number, no hours: 1 | 2 | 3; la fecha es string, no “una fecha válida”. parseBookingRequest solo revisa la forma (como dice su comentario: “las reglas del negocio se revisan después”), y las reglas (de 1 a 3 horas, dentro del horario) las revisa checkBookingRules, que devuelve null o una violación pero no un tipo más preciso. Es una decisión razonable para una aplicación pequeña; lo que se pierde es que nada en los tipos impide llamar a una función de dominio con un BookingRequest que todavía no pasó por checkBookingRules.

King da otro consejo que se aplica directo a Kallpa: si la misma información se guarda en dos lugares, el estado “los dos lugares no coinciden” se vuelve representable. Prefieran una sola fuente, como BOOKING_STATUSES.

En la industria. En TypeScript, el patrón se usa sobre todo en la frontera con bibliotecas de esquemas como Zod o Valibot: se declara un esquema y la biblioteca da a la vez la función que parsea un unknown y el tipo que sale de ella, para que no se desincronicen. El curso no usa dependencias de npm en Kallpa, así que se escribe a mano, como parseBookingRequest; la idea es la misma.

as no revisa nada

x as Booking le dice a tsc “confía en mí: esto es un Booking”. tsc lo acepta (con pocas restricciones) y no agrega ninguna revisión: en tiempo de ejecución, as desaparece como todos los tipos. Es una afirmación que nadie comprueba. En types.test.ts:

  test('`as` no revisa nada: 7 as BookingHours pasa y da una hora de fin imposible', () => {
    assert.equal(endHour(19, 7 as BookingHours), 26);
  });

La prueba pasa: tsc compila sin quejarse, y endHour devuelve las 26:00. Toda la garantía de BookingHours se evaporó con dos palabras.

Kallpa tiene un as de este tipo en toBookingJson (src/http/handlers.ts):

    status: row.status as BookingStatus,

La fila de SQLite tiene status: string, y el as le dice a tsc que es 'confirmed' | 'cancelled'. Hoy es cierto porque solo el código de Kallpa escribe en la tabla. Pero si alguien guarda 'canceled' desde una consola de SQLite, el valor entra al tipo Booking sin que nadie lo note, y un switch exhaustivo sobre el estado llegaría al default en producción. Lo que haría falta es parsear (revisar que el texto esté en BOOKING_STATUSES y fallar si no). Lo veremos la semana que viene, cuando discutamos por qué la fila de la base de datos llega hasta la capa HTTP.

No todo as es un problema. El de parseBookingRequest (body as Record<string, unknown>) viene justo después de un if que comprobó que body es un objeto. Y el de un tipo marcado, que vemos a continuación, está dentro de la única función que revisa. La regla práctica: un as tiene que estar justo después de la revisión que lo justifica, o dentro de la función que la hace, nunca lejos de ella.

Error común. Usar as para “convencer” al compilador cuando se queja: const booking = JSON.parse(body) as Booking. El error de compilación desaparece y el problema no: si el JSON no tiene la forma de un Booking, el programa falla más adelante, en otro lugar, con un error que no menciona el as. Cuando tsc se queja de un unknown, la respuesta es parsear.

Tipos marcados

Kallpa maneja el dinero en céntimos enteros. Pero para tsc, céntimos, soles, horas y ids son todos number, y nada impide sumar un precio en soles con uno en céntimos. Un tipo marcado (branded type) agrega una marca que solo existe en los tipos (cents.ts):

export type Cents = number & { readonly __brand: 'Cents' };
export type Soles = number & { readonly __brand: 'Soles' };

// El único lugar donde se escribe `as Cents`: después de revisar.
export function toCents(value: number): Cents {
  if (!Number.isSafeInteger(value)) {
    throw new RangeError(`Los céntimos son enteros y llegó ${value}.`);
  }
  return value as Cents;
}

export function addCents(a: Cents, b: Cents): Cents {
  return (a + b) as Cents;
}

Un number común no tiene la propiedad __brand, así que no se puede pasar donde se pide Cents; un Soles tiene otra marca, así que tampoco (bad/brands.ts):

bad/brands.ts(5,17): error TS2345: Argument of type 'Soles' is not assignable to parameter of type 'Cents'.
  Type 'Soles' is not assignable to type '{ readonly __brand: "Cents"; }'.
    Types of property '__brand' are incompatible.
      Type '"Soles"' is not assignable to type '"Cents"'.
bad/brands.ts(6,17): error TS2345: Argument of type 'number' is not assignable to parameter of type 'Cents'.
  Type 'number' is not assignable to type '{ readonly __brand: "Cents"; }'.

Tres cosas para no sobrestimar la técnica:

  1. La marca no existe en tiempo de ejecución. Un Cents es un number: typeof dice 'number' y JSON.stringify lo escribe como cualquier número (lo comprueba types.test.ts). La marca es una ficción útil para tsc.
  2. Hace falta un as, y solo uno, dentro de la función que revisa (toCents). Es la técnica que King llama, para los casos en que no se puede hacer imposible un estado inválido, un tipo abstracto con un “constructor inteligente”: la función que revisa es la única puerta.
  3. La marca no garantiza que el número sea entero. Lo garantiza toCents, si todos pasan por ella. Cualquiera puede escribir 112.5 as Cents, y types.test.ts comprueba que eso compila y suma 212.5 sin quejarse. Además, addCents usa as para el resultado de la suma: es correcto porque la suma de dos enteros seguros es entera mientras no pase de Number.MAX_SAFE_INTEGER, algo que el tipo tampoco dice.

Lo que los tipos no dicen

Las reglas más importantes de Kallpa no caben en un tipo de TypeScript:

Propiedad ¿La revisa tsc? Dónde va
status es 'confirmed' o 'cancelled' Sí, si no hay un as en el camino El tipo
Un resultado de error se mira antes de usar el éxito Sí Unión discriminada
hours es 1, 2 o 3 Sí con BookingHours; Kallpa usa number El tipo, o una revisión en tiempo de ejecución
La reserva está entre las 7:00 y las 22:00 No Especificación escrita, checkBookingRules, pruebas
priceCents es entero No (ni con una marca) toCents o equivalente, pruebas
No se cruza con otra reserva confirmada No: depende de otros datos Invariante escrito, el handler, pruebas (y en la semana 7, quizá la base de datos)
La fecha existe en el calendario No Una función que parsee la fecha, pruebas
Se puede cancelar hasta 3 horas antes No isCancellableAt, pruebas

Los tipos son la parte de la especificación que se revisa sola y gratis en cada compilación. Conviene empujar hacia ellos todo lo que se pueda, pero lo que no cabe tiene que quedar escrito en otro lado: en la especificación, en una revisión en tiempo de ejecución y en las pruebas que la comprueban.

Con IA. Cuando se le pide a una herramienta de IA que “arregle” un error de compilación, una de las salidas posibles es el camino más corto: un as o un any. Por ejemplo, un cambio como los que suele proponer un agente ante “Argument of type ‘unknown’ is not assignable…” es JSON.parse(body) as BookingRequest: el error desaparece y la revisión también. tsc queda contento, así que no lo va a avisar. Al revisar un cambio, busquen los as, los any y los // @ts-ignore nuevos (git diff | grep -E 'as |any|ts-ignore' es un buen comienzo) y pidan, para cada uno, la revisión que lo justifica.

Design docs

Qué es y para qué sirve

Las especificaciones de la primera parte describen funciones. Cuando lo que hay que construir es más grande (una funcionalidad nueva, un servicio, un cambio de arquitectura), hace falta pensar antes de escribir código, y pensarlo con otros. Para eso existe el design doc: un documento informal que escriben los autores de un sistema antes de empezar a programarlo, y que registra la estrategia de implementación a alto nivel y las decisiones de diseño, con énfasis en los trade-offs que se consideraron (Ubl, Design Docs at Google).

Ubl parte de una idea simple: el trabajo de quien desarrolla no es producir código, sino resolver problemas. Al principio de un proyecto, texto sin estructura puede ser mejor herramienta que el código: más corto, más fácil de entender, y habla del problema y la solución a un nivel más alto. Además de documentar el diseño, un design doc sirve para:

  • encontrar problemas de diseño cuando cambiar todavía es barato;
  • llegar a un consenso sobre el diseño en la organización;
  • asegurarse de que se consideraron los temas transversales (seguridad, privacidad, observabilidad);
  • llevar el conocimiento de las personas con más experiencia al resto, que aprende leyendo y comentando diseños;
  • dejar una memoria de por qué se decidió lo que se decidió.

Las secciones

Ubl insiste en que la regla número uno es escribirlo en la forma que más sentido tenga para el proyecto. Aun así, una estructura se volvió habitual:

Contexto y alcance. Una vista rápida del entorno en que se construye el sistema y de qué se va a construir. No es un documento de requisitos: hechos objetivos, breves, con enlaces para el detalle.

Objetivos y no objetivos. Una lista corta de lo que el sistema tiene que lograr y, a veces más importante, de lo que se decide no hacer. Un no objetivo no es un objetivo negado (“el sistema no debe caerse”); es algo que razonablemente podría ser un objetivo y que se elige no perseguir. El ejemplo de Ubl: al diseñar una base de datos, “cumplir ACID” puede ser objetivo o no objetivo, y quien lee necesita saber cuál.

El diseño. Empieza con una vista general y baja al detalle. Ubl recomienda un diagrama de contexto (el sistema dentro del paisaje que el lector ya conoce), un bosquejo de la API si la hay y la forma aproximada de los datos. Advierte contra pegar definiciones completas de interfaces o esquemas: son largas, tienen detalle innecesario y se desactualizan rápido. Código o pseudocódigo, rara vez, salvo para un algoritmo nuevo.

Alternativas consideradas. Otros diseños que habrían logrado algo parecido, con los trade-offs de cada uno y por qué se descartaron.

Temas transversales. Secciones cortas sobre cómo el diseño afecta la seguridad, la privacidad, la observabilidad, y cómo se atiende cada tema. Cada organización decide cuáles son obligatorios.

¿Qué tan largo? Para un proyecto grande, Ubl habla de unas 10 a 20 páginas; y también de mini design docs de 1 a 3 páginas para mejoras incrementales o subtareas en un equipo ágil, con los mismos pasos en menos espacio. Para P2 escribirán uno de ese tamaño: el enunciado (projects/p2-diseno/) pide las secciones Contexto, Objetivos, No objetivos, Diseño, Alternativas consideradas y Riesgos.

Un mini design doc: turnos fijos en Kallpa

Volvamos al pedido de la semana pasada: los equipos de siempre quieren su hora fija todas las semanas. Supongamos que las historias ya están escritas y priorizadas. Antes de programar, un mini design doc podría verse así (los números y algunos detalles son supuestos para el ejemplo, no lo que dijo Doris en su entrevista):

# Turnos fijos

Autores: equipo Kallpa · Estado: en revisión · Revisores: Doris (administración), equipo

## Contexto y alcance

Hoy cada reserva es de un día. Unos diez equipos juegan todas las semanas a
la misma hora y la administración les reserva a mano, semana por semana. Se
quiere que un turno fijo ("Vóley Femenino San Juan, cancha 3, jueves de 19:00
a 21:00") reserve esa hora todas las semanas de una temporada. Este
documento cubre cómo se guarda y cómo se muestra un turno fijo; el cobro no
cambia.

## Objetivos

- Que la administración cree un turno fijo para una cancha, un día de la
  semana, un horario y una temporada (fecha de inicio y de fin).
- Que la disponibilidad y la caja del día vean esas horas como ocupadas,
  igual que una reserva común.
- Que se pueda saltar una semana (feriado, mantenimiento) sin cancelar todo
  el turno.
- Que el invariante se mantenga: una cancha no tiene dos reservas
  confirmadas que se crucen, contando las de los turnos fijos.

## No objetivos

- Que los vecinos creen turnos fijos desde la página: por ahora solo la
  administración.
- Cobrar por adelantado la temporada: cada semana se cobra como hoy.
- Turnos cada dos semanas u otras repeticiones: solo semanal.

## Diseño

(diagrama: página de administración → API → servicio de reservas → SQLite)

Al crear un turno fijo se crean de una vez las reservas de toda la
temporada, una por semana, con un campo nuevo `seriesId` que las agrupa.
Antes de crear la primera, se revisa que ninguna de las fechas choque con
una reserva confirmada; si alguna choca, no se crea ninguna y se responde
con la lista de fechas en conflicto. Saltar una semana es cancelar esa
reserva. Terminar el turno es cancelar las reservas futuras de la serie.

API: `POST /api/series` (crea), `POST /api/series/:id/cancel` (cancela las
futuras). Datos: tabla `series` (cancha, día, hora, horas, equipo, inicio,
fin) y columna `series_id` en `bookings`.

## Alternativas consideradas

**Guardar la regla y calcular las fechas al vuelo.** Se guarda solo
"jueves 19:00-21:00 del 5 de marzo al 25 de junio" y la disponibilidad
calcula, para cada día, si alguna regla lo ocupa. Cambiar el turno (otro
horario desde abril) es editar una fila. Pero cada consulta de
disponibilidad, cada revisión de cruce y la caja tienen que mirar dos
fuentes (reservas y reglas), saltar una semana necesita una tabla de
excepciones, y el invariante deja de ser "no hay dos filas cruzadas" para
ser una cuenta entre filas y reglas. Se descarta: el costo cae en todas las
consultas para ahorrar en un cambio que según la administración pasa pocas
veces por temporada.

**No hacer nada en el sistema.** La administración sigue reservando a mano.
Cero costo de desarrollo; es lo que hacen hoy y es la queja.

## Temas transversales

- Privacidad: el turno muestra el nombre del equipo como las reservas de hoy;
  no se agregan datos personales.
- Observabilidad: se registra en el log quién creó o canceló una serie.

## Riesgos

- Cada turno crea una fila por semana: 17 para una temporada de marzo a
  junio, unas 170 para diez equipos. No es un problema para SQLite.
- Si la temporada cambia de fechas a mitad de camino, hay que cancelar y
  crear de nuevo: aceptable si pasa poco; si pasa seguido, revisar la
  alternativa descartada.

Fíjense en tres cosas. Los no objetivos son cosas que alguien podría esperar (que los vecinos creen turnos, cobrar la temporada). El diseño no pega el esquema completo de SQL ni el código, solo lo necesario para discutir. Y las alternativas comparan con argumentos del contexto (cuántos equipos, cada cuánto cambian los turnos), no en abstracto.

Las alternativas son la parte más útil

Ubl dice que la sección de alternativas es de las más importantes: muestra explícitamente por qué la solución elegida es la mejor para los objetivos, y responde de antemano a quien se pregunte “¿y por qué no hicieron X?”. Un design doc que no muestra ninguna otra opción no deja ver los trade-offs: el lector no puede saber si la solución se eligió o fue la primera que se le ocurrió a alguien.

En el ejemplo, las dos alternativas reales para turnos fijos tienen ventajas opuestas: crear de una vez las reservas de toda la temporada es más simple de consultar (todo lo que ya existe sigue funcionando con reservas comunes); guardar la regla y calcular las fechas al vuelo es más simple de cambiar (una fila en lugar de veinte). Cuál conviene depende de qué pasa más seguido en el complejo, y eso es una pregunta para el cliente, no para el equipo.

Idea clave. Un design doc se escribe antes de construir, para pensar y recibir críticas cuando cambiar es barato. Su valor está en los trade-offs: los objetivos y no objetivos dicen qué se busca, y las alternativas dicen por qué se eligió este camino y no otro.

Cuándo no escribirlo

Escribir un design doc cuesta tiempo, y revisarlo, más. Ubl plantea la decisión como un trade-off: ¿los beneficios (consenso, documentación, revisión de gente con experiencia) compensan el trabajo extra? En el centro está si la solución es ambigua, por la complejidad del problema, de la solución o de las dos. Si no lo es, el documento aporta poco.

La señal más clara de que sobra: un design doc que en realidad es un manual de implementación. Si dice “así lo vamos a implementar” sin trade-offs, sin alternativas y sin explicar las decisiones (o porque la solución es tan obvia que no hubo trade-offs), habría sido mejor escribir el programa directamente. Ubl agrega que un prototipo puede ser parte del design doc: “lo probé y funciona” es uno de los mejores argumentos para un diseño.

Error común. Escribir el design doc después de construir, porque hay que entregarlo. A esa altura ninguna decisión puede cambiar, nadie lo revisa con la intención de cambiar algo, y las “alternativas consideradas” se inventan para llenar la sección. Si el documento llega tarde, es mejor escribir un ADR honesto (semana 7) que un design doc de utilería.

El ciclo de vida

Ubl describe cuatro etapas: creación e iteración rápida con quienes más saben del problema; revisión con un público más amplio (desde mandar el documento a la lista del equipo hasta una reunión formal de revisión de diseño); implementación e iteración, donde el plan choca con la realidad y el documento se actualiza (como regla: si el sistema todavía no salió a producción, actualícenlo siempre); y mantenimiento y aprendizaje, cuando alguien que llega al sistema pregunta “¿dónde está el design doc?”. Ubl recomienda releer los propios design docs uno o dos años después: qué se acertó, qué no, qué se decidiría distinto.

El valor principal de la revisión, dice Ubl, no es tanto que encuentre problemas, sino que los encuentra temprano, cuando corregirlos es barato.

En la industria. En Google, según Ubl, la gran mayoría de los design docs se escriben en Google Docs, con comentarios en el documento, y muchos equipos tienen reuniones periódicas de revisión de diseño. SWEG (cap. 10) agrega que la mayoría de los equipos exigen un design doc aprobado antes de empezar un proyecto grande, con un template que obliga a considerar seguridad, internacionalización, almacenamiento y privacidad, partes que revisan especialistas. Otras empresas usan la misma idea con otro nombre: RFC (request for comments) es habitual. En la semana 7 veremos los ADR, que registran una decisión a la vez.

La documentación como código

Tratar los documentos como se trata el código

El capítulo 10 de SWEG empieza con un diagnóstico: el estado de la documentación en la ingeniería de fines de la década de 2010 se parece al de las pruebas a fines de los 80. Todos saben que hay que hacer más, pero no hay todavía un reconocimiento organizacional de su valor. Y propone una respuesta: los esfuerzos que mejor funcionaron en Google fueron los que trataron la documentación como código, metida en el flujo de trabajo de siempre. Según el capítulo, la documentación debería:

  • tener reglas o políticas internas que se siguen;
  • estar bajo control de versiones;
  • tener un owner claro, responsable de mantenerla;
  • pasar por revisión cuando cambia, y cambiar junto con el código que documenta;
  • tener sus problemas registrados como se registran los bugs;
  • evaluarse periódicamente;
  • si se puede, medirse en exactitud y actualidad (el capítulo admite que las herramientas todavía no llegan ahí).

El capítulo cuenta lo que pasó en Google con una wiki interna: sin owners, los documentos quedaban obsoletos; sin proceso, se duplicaban (llegó a haber de 7 a 10 documentos sobre cómo configurar el mismo sistema, casi ninguno mantenido). Lo que mejoró la situación fue mover la documentación importante al control de versiones, con owners, ubicación canónica junto al código y revisión de cambios. Hubo resistencia (se temía que exigir revisión bajara la cantidad y la calidad), pero los documentos mejoraron.

Para un equipo como los de este curso, en concreto:

  • Las especificaciones y los design docs viven en el repositorio, en docs/, como Markdown (en el laboratorio de hoy, docs/specs/horas-posibles.md).
  • Un pull request que cambia el comportamiento de una función cambia también su especificación, en el mismo PR. Quien revisa el código revisa los dos.
  • Cada documento tiene un owner: alguien a quien preguntarle y que lo mantiene al día.

¿Y los design docs en Google Docs, que mencionaba Ubl? SWEG no lo contradice: dice que para discutir un diseño se usa a menudo Google Docs, por la colaboración, pero que en algún momento todo documento compartido deja de ser una discusión y pasa a ser un registro histórico estable, y ahí conviene moverlo a un lugar permanente, con owner y control de versiones.

Cada documento tiene un público y un tipo

SWEG insiste en dos ideas más. La primera: saber para quién se escribe. El error más común es escribir solo para uno mismo. Un design doc puede tener que convencer a quien decide; un tutorial, guiar a alguien que no conoce el código; una referencia, ser completa y exacta para cualquiera que use la API. El capítulo distingue también entre quienes buscan algo concreto (seekers, que necesitan consistencia para encontrarlo rápido) y quienes llegan sin saber bien qué buscan (stumblers, que necesitan una introducción clara al principio).

La segunda: cada documento tiene un tipo, y no se mezclan tipos. Un documento con un solo propósito, como una API que hace una cosa bien. Los tipos que describe el capítulo:

Tipo Para qué En Kallpa
Referencia (incluye comentarios en el código) Cómo se usa el código El comentario de contrato sobre checkBookingRules; la tabla de la API del README
Design doc Proponer un diseño y sus trade-offs antes de construir Turnos fijos, arriba
Tutorial Llevar a alguien nuevo, paso a paso, a hacer algo real “Cómo correrla” del README
Conceptual Explicar una idea que la referencia no alcanza a transmitir “Por qué las fechas son texto y las horas, enteros” del README
Página de entrada (landing page) Dirigir a los otros documentos, sin contenido propio El índice de docs/ de un proyecto

Un ejemplo de mezcla que el capítulo critica: poner decisiones de diseño dentro de la documentación de referencia de una API. A quien usa la API no le sirven; van en un design doc. Del mismo modo, una especificación de función (referencia) no es el lugar para discutir por qué se eligió esa firma (eso es diseño).

En la industria. SWEG (cap. 10) cuenta que Google terminó creando su propio sistema para tener la documentación al lado del código: una carpeta de Markdown por API, que se lee en el mismo navegador de código y se puede cambiar en el mismo cambio que el código (el capítulo admite que todavía cuesta que todos lo hagan). Es la misma idea que docs/ en un repositorio de GitHub, donde un archivo .md se ve renderizado y se revisa en el pull request.

Desarrollo guiado por especificaciones

Qué es

Con las herramientas de IA que escriben código, la especificación volvió al centro de la conversación con otro nombre: desarrollo guiado por especificaciones (spec-driven development, SDD). Birgitta Böckeler, en la lectura de octubre de 2025, advierte primero que la definición todavía está en movimiento, y propone esta: escribir una especificación antes de escribir código con IA (“documentación primero”); la especificación pasa a ser la fuente de verdad para la persona y para la IA.

¿Qué es una especificación en este contexto? Böckeler reconoce que no hay una definición general; lo más parecido que encontró es la comparación con un Product Requirements Document. Su propuesta: un artefacto (o un conjunto de artefactos) estructurado y orientado al comportamiento, escrito en lenguaje natural, que expresa la funcionalidad del software y sirve de guía a los agentes de IA. Lo distingue de los documentos de contexto generales del repositorio (archivos de reglas, descripciones del producto y del código), que algunas herramientas llaman memory bank y que valen para todas las sesiones, mientras que una especificación vale solo para las tareas que crean o cambian esa funcionalidad. En la semana 14 volveremos a esos archivos de contexto.

Tres niveles

Böckeler distingue tres niveles de compromiso con la especificación:

  • Spec-first. Se escribe una especificación pensada antes de empezar y se usa en el trabajo con IA para esa tarea.
  • Spec-anchored. La especificación se conserva después de terminar la tarea, para seguir usándola en la evolución y el mantenimiento de esa funcionalidad.
  • Spec-as-source. La especificación es el archivo principal con el tiempo: la persona solo edita la especificación y nunca toca el código, que se regenera.

Según su revisión, todos los enfoques que encontró son spec-first, pero no todos aspiran a los otros dos niveles, y muchas veces queda vago qué se hace con la especificación después.

Tres herramientas, según Böckeler

Böckeler probó tres herramientas en septiembre de 2025 y aclara que cambian rápido; lo que sigue es lo que ella observó, no una descripción actual:

  • Kiro, la más liviana: tres documentos en Markdown, requisitos (historias de usuario con criterios given, when, then), diseño y tareas que remiten a los números de los requisitos. Le pareció sobre todo spec-first.
  • spec-kit, de GitHub: una herramienta de línea de comandos que prepara el repositorio para varios asistentes de código, con un documento de principios generales (la “constitución”) y un flujo specify, plan, tasks que genera muchos archivos con listas de verificación. Aunque GitHub habla de especificaciones “vivas”, crea una rama por especificación, y por eso Böckeler la clasifica como spec-first.
  • Tessl, entonces en beta privada: la única de las tres que apunta a spec-anchored y explora spec-as-source, con especificaciones que se corresponden una a una con archivos de código marcados como generados.

Lo que gana y lo que cuesta

Lo que se gana es fácil de ver: en lugar de adivinar, el agente trabaja con un acuerdo explícito, y la especificación se puede revisar antes de que exista el código, cuando cambiarla es barato (el mismo argumento que el de los design docs). Böckeler cuenta que ella misma suele dedicar tiempo a escribir una especificación antes de darle una tarea a un agente, y que el principio de spec-first es valioso en muchas situaciones.

Lo que cuesta, según sus observaciones:

  • Un flujo para todos los tamaños. Le pidió a una herramienta arreglar un bug pequeño y el documento de requisitos lo convirtió en 4 historias de usuario con 16 criterios de aceptación. Con otra, para una funcionalidad mediana, calcula que en el tiempo que le tomó correr y revisar los pasos habría terminado la funcionalidad con asistencia de IA común, sin ese flujo.
  • Revisar Markdown en lugar de código. Muchos archivos, repetitivos entre sí y con el código existente. Dice que prefería revisar código.
  • Una falsa sensación de control. Con todos los archivos, templates y listas, vio al agente no seguir todas las instrucciones (por ejemplo, tomar como especificación nueva la descripción de clases que ya existían y generarlas de nuevo, duplicadas) y también seguir alguna con demasiado celo.
  • Separar lo funcional de lo técnico, que la profesión no siempre hizo bien con las historias de usuario.
  • El paralelo con model-driven development: generar código a partir de modelos se intentó antes y no se impuso en aplicaciones de negocio. Con lenguaje natural en lugar de un lenguaje formal se pierde la ayuda de herramientas para escribir especificaciones válidas y completas, y se gana el no determinismo de los modelos de lenguaje (ella misma vio código distinto generado varias veces desde la misma especificación).

Hay que leer estas observaciones como lo que son: la experiencia de una ingeniera con mucha trayectoria, con tres herramientas, en un momento dado y sobre pocos problemas. No es un estudio con mediciones. Su valor está en que nombra los riesgos que ustedes van a poder comprobar en el laboratorio.

Donde la especificación calla, el agente elige

El riesgo más importante para este curso es el de la ambigüedad. Si la especificación no dice algo, el agente no se detiene a preguntar: elige. Y esa elección no aparece escrita en ninguna parte; está en el código.

Un ejemplo. La página de un vecino quiere mostrar sus próximas reservas, y la especificación dice solo: “devuelve las próximas reservas del vecino”. La siguiente implementación está escrita para esta clase, como ilustración de lo que suele devolver un agente cuando la especificación calla (upcoming.ts):

export function upcomingBookingsGuess(bookings: readonly Booking[], now: LimaTime): Booking[] {
  return bookings
    .filter((b) => b.date >= now.date) // "próximas": de hoy en adelante
    .sort((a, b) => a.date.localeCompare(b.date)); // por fecha
}

Se ve bien, y cada decisión es razonable por separado. Pero tomó tres decisiones que la especificación no tomaba: incluye las reservas canceladas (no filtra por estado), incluye una reserva de hoy que ya empezó (compara solo la fecha) y, en la misma fecha, deja las reservas en el orden en que llegaron (no ordena por hora). Ninguna de las tres se anuncia.

Cuando se le pregunta a la administración, la especificación completa dice: las confirmadas que todavía no empiezan, ordenadas por fecha y hora. Las pruebas de aceptación derivadas de esa especificación (upcoming-cases.ts), corridas contra la versión que adivinó (make demos):

$ node --test demos/upcoming-guess.test.ts
✔ devuelve las reservas futuras ordenadas por fecha (6.83175ms)
✖ no devuelve las reservas canceladas (0.716625ms)
✖ no devuelve una reserva de hoy que ya empezó (0.1385ms)
✖ en la misma fecha, ordena por hora de inicio (0.151792ms)
ℹ tests 4
ℹ suites 0
ℹ pass 1
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 86.503375

El detalle de cada falla dice qué eligió la versión que adivinó. Por ejemplo, la tercera:

✖ en la misma fecha, ordena por hora de inicio (0.151792ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
  + actual - expected
  
    [
  +   1,
      2,
  -   1
    ]

La única prueba que pasa es la del caso feliz. Si el equipo hubiera probado solo ese caso, o hubiera aceptado el código porque “cumple la especificación” (la de una línea), las tres decisiones habrían llegado a producción sin que nadie las tomara.

Cómo se comprueba que el código cumple la especificación

Una especificación no comprueba nada sola. Lo que la convierte en algo que se puede verificar son pruebas de aceptación derivadas de ella, escritas o revisadas por una persona. Algunas reglas prácticas:

  1. Revisen la especificación antes que el código. Es más corta y es donde se toman las decisiones. Usen la lista de la sección de postcondiciones: ¿una implementación tonta la cumple?, ¿qué pasa en los bordes?
  2. Las pruebas de aceptación no las escribe solo quien implementa, sea una persona o un agente. Si el mismo agente escribe el código y las pruebas, y las dos cosas salen de la misma lectura de la especificación, las pruebas confirman esa lectura, no la especificación. Las pruebas que más valen son las que escribe alguien que leyó la especificación por su cuenta (en el laboratorio, la administración).
  3. Lean el código buscando decisiones, no errores. Para cada if, filter y sort, pregúntense: ¿esto lo dice la especificación? Si no lo dice, es una decisión que alguien tomó por ustedes.
  4. Cuando una prueba de aceptación falla, primero decidan quién tiene razón. Puede estar mal el código, o puede estar mal (o incompleta) la especificación. Corrijan primero la especificación y después el código.
Arriba, de izquierda a derecha: la especificación, que escribe una persona; su revisión, que pregunta si una implementación tonta la cumple y que puede devolverla a la especificación, porque cambiarla ahí es barato; la implementación, de una persona o un agente, con la nota de que donde la especificación calla, el agente elige; y el código, que se lee buscando decisiones. En el centro, las pruebas de aceptación, que no escribe solo quien implementa: se derivan de la especificación por una flecha aparte y se corren contra el código. Debajo, la pregunta de si pasan. Si fallan y la especificación estaba mal o incompleta, una flecha vuelve a la especificación, que se corrige primero; si fallan y el código no hace lo que dice la especificación, otra flecha vuelve al código. Si pasan, se aceptaArriba, de izquierda a derecha: la especificación, que escribe una persona; su revisión, que pregunta si una implementación tonta la cumple y que puede devolverla a la especificación, porque cambiarla ahí es barato; la implementación, de una persona o un agente, con la nota de que donde la especificación calla, el agente elige; y el código, que se lee buscando decisiones. En el centro, las pruebas de aceptación, que no escribe solo quien implementa: se derivan de la especificación por una flecha aparte y se corren contra el código. Debajo, la pregunta de si pasan. Si fallan y la especificación estaba mal o incompleta, una flecha vuelve a la especificación, que se corrige primero; si fallan y el código no hace lo que dice la especificación, otra flecha vuelve al código. Si pasan, se acepta
Las pruebas de aceptación llegan desde la especificación por otro camino que el código. Cuando una falla, la primera pregunta es quién tenía razón.

Error común. Darle al agente una especificación ambigua y aceptar lo que devuelve porque “cumple la especificación”. Donde la especificación calla, el agente elige por ustedes, y esa elección no aparece en ninguna parte. La pregunta no es si el código cumple la especificación, sino si la especificación decía lo suficiente para que cumplirla sea lo que el cliente quería.

Con IA. El desarrollo guiado por especificaciones cambia dónde se pone el esfuerzo, no cuánto. Escribir una especificación que un agente pueda seguir obliga a decidir lo que antes se decidía programando. Eso es bueno si las decisiones se revisan; es peligroso si se cree que la especificación ya garantiza el resultado. Cómo comprobarlo: pruebas de aceptación escritas o revisadas por una persona a partir de la especificación, y lectura del código buscando decisiones que la especificación no tomaba. Lo que produzca la herramienta de cada uno se discute en el laboratorio, con su registro de uso.

El laboratorio: de la especificación al código

El pedido

La página de Kallpa muestra hora por hora qué está libre, pero quien quiere 2 o 3 horas tiene que buscar a mano dónde caben. La administración pide una función de dominio que diga a qué horas puede empezar una reserva de N horas en una cancha un día dado:

possibleStartHours(bookings: readonly Booking[], date: string, hours: number, now: LimaTime): number[]

en src/domain/possible-start-hours.ts de su copia de Kallpa. El pedido completo, lo que respondió la administración a las preguntas de otro grupo y el template de la especificación están en code/week-04/horas-posibles.md. La guía de la semana tiene los pasos; aquí va lo que conviene tener en la cabeza mientras los hacen.

Antes de programar: la especificación

El template sigue la estructura de este capítulo: firma, precondiciones (y para cada una, si la función la revisa o la exige), postcondiciones, invariante del que depende y una tabla de ejemplos con los bordes. Algunas preguntas para hacerse:

  • La administración dice qué pasa con las canceladas, con las horas de hoy que ya empezaron y con el orden. No dice qué pasa con hours igual a 0 o a 4, ni con una fecha fuera de las dos semanas. Ustedes deciden: ¿exigir o revisar? Si exigen, ¿quién garantiza la precondición en Kallpa? Si revisan, ¿qué devuelven o qué lanzan?
  • bookings son las reservas de una cancha para ese día. ¿La función confía en eso (precondición) o filtra por fecha por si acaso? Recuerden lo que dice Meyer de revisar dos veces.
  • Pasen la postcondición por la lista: ¿una función que devuelve siempre [] la cumple?, ¿y una que devuelve todas las horas sin mirar las reservas?
  • ¿Qué invariante da por cierto? ¿Qué pasaría con su función si hubiera dos reservas confirmadas cruzadas?
  • En la tabla de ejemplos, ¿están la primera y la última hora del día, una reserva que termina justo cuando empezaría la nueva, el día vacío, el día lleno y “hoy”?

Otra pareja revisa la especificación y marca lo ambiguo. Una buena revisión no corrige la redacción: busca lo que la especificación permite y el cliente no querría.

Con y sin agente

Media clase implementa la función a mano, a partir de su especificación, con lo que ya existe en src/domain/booking.ts (overlaps, OPENING_HOUR, CLOSING_HOUR, checkBookingRules). La otra media le da solo la especificación y la firma a la herramienta de IA que tenga. Las dos mitades escriben sus propias pruebas a partir de su tabla de ejemplos.

Al final, el docente publica las pruebas de aceptación de la administración. Cuando una falle, no corran a cambiar el código: primero decidan si el error está en el código (la especificación decía otra cosa) o en la especificación (un caso que no se previó, o que se previó distinto de lo que quería la administración). Corrijan primero la especificación y después el código, hasta que make check pase.

La comparación del final es el centro del laboratorio: ¿quién terminó antes?, ¿en qué casos eligió el agente algo que la especificación no decía?, ¿lo habrían notado sin las pruebas de la administración? Si usaron una herramienta, el registro en docs/registro-ia.md dice qué se le pidió, qué se aceptó, qué se cambió y por qué.

El código de la semana y make test

La carpeta code/week-04 tiene los ejemplos de este capítulo y sus pruebas. Los ejemplos importan el código de Kallpa (../../kallpa/src/...) sin modificarlo. Lo que falla a propósito está separado: bad/ tiene los archivos que no compilan (make errores muestra los errores de tsc) y demos/ las pruebas que fallan (make demos). Las pruebas de typecheck.test.ts y demos.test.ts comprueban que fallen exactamente como dice el capítulo.

$ make test
rm -rf build/kallpa
mkdir -p build
cp -r ../../kallpa build/kallpa
rm -rf build/kallpa/data build/kallpa/node_modules
mkdir -p build/kallpa/data
node --test demos.test.ts free-hours.test.ts invariant.test.ts overlaps.test.ts soles.test.ts typecheck.test.ts types.test.ts upcoming.test.ts
✔ la versión que adivina falla 3 de las 4 pruebas de aceptación (101.733167ms)
▶ la prueba débil: "todo lo que devuelve está libre"
...
✔ devuelve las reservas futuras ordenadas por fecha (6.823459ms)
✔ no devuelve las reservas canceladas (0.08975ms)
✔ no devuelve una reserva de hoy que ya empezó (0.073625ms)
✔ en la misma fecha, ordena por hora de inicio (0.079167ms)
ℹ tests 31
ℹ suites 8
ℹ pass 31
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 902.849208
(sin solution/: las pruebas de aceptación del laboratorio se publican al final del laboratorio)

La última línea es la que verán hasta que termine el laboratorio: las pruebas de aceptación de possibleStartHours se publican al final.

Archivo Qué muestra
overlaps.test.ts El contrato de overlaps: la postcondición en los 1764 pares, y qué pasa fuera de la precondición
invariant.ts, invariant.test.ts El invariante de las reservas, y que la base de datos no lo protege
soles.ts, soles.test.ts Tres contratos para formatSoles
free-hours.ts, free-hours.test.ts Postcondición incompleta frente a completa
booking-result.ts, hours.ts, cents.ts, types.test.ts Uniones discriminadas, never, parsear frente a validar, as, tipos marcados
bad/*.ts, typecheck.test.ts Los errores de tsc del capítulo
upcoming.ts, upcoming-cases.ts, upcoming.test.ts, demos/ Una especificación ambigua, la versión que adivina y las pruebas de aceptación
horas-posibles.md El pedido y el template del laboratorio

Resumen

  • Una especificación dice qué hace un módulo sin decir cómo. Es un acuerdo que permite cambiar el código de cada lado sin preguntar al otro; sin ella, el acuerdo es todo lo que el código hace (la ley de Hyrum).
  • Un contrato (Meyer) tiene precondición (la obligación de quien llama), postcondición (la obligación de la rutina si se cumplió la precondición) e invariante (lo que vale entre operaciones). Si la precondición no se cumple, la rutina no promete nada; una violación de la precondición es un bug de quien llama, y una de la postcondición, de la rutina.
  • El invariante de Kallpa (una cancha no tiene dos reservas confirmadas que se crucen) lo mantiene el handler de las reservas; la base de datos no lo revisa.
  • Con precondición fuerte la rutina exige y es parcial; con precondición débil revisa y, si responde algo definido para toda entrada, es total. Las dos son válidas; la especificación tiene que decir cuál se eligió. Revisar en todas las capas “por si acaso”, o en ninguna, viene de no haberlo decidido.
  • Una postcondición completa dice que lo devuelto es correcto y que está todo, en qué orden y sin repetidos. Si return [] la cumple, está incompleta. Lo mismo vale para las pruebas.
  • Los tipos son la parte de la especificación que tsc revisa sola: uniones de literales en lugar de string, objetos as const en lugar de enum, uniones discriminadas para resultados que pueden fallar y switch exhaustivo con never.
  • Parse, don’t validate: en la frontera, convertir el dato sin tipo en un tipo que lleva la garantía, una sola vez. as no revisa nada; un as solo se justifica junto a la revisión que lo respalda. Un tipo marcado evita mezclar céntimos con soles, pero no garantiza que el número sea entero.
  • Lo que los tipos no dicen (horarios, rangos, cruces) va en la especificación escrita, en revisiones en tiempo de ejecución y en pruebas.
  • Un design doc se escribe antes de construir: contexto y alcance, objetivos y no objetivos, el diseño, las alternativas consideradas y los temas transversales. Las alternativas muestran los trade-offs. Si la solución es obvia, no hace falta; escrito después de construir, no sirve.
  • La documentación como código vive en el repositorio, tiene owner, se revisa en los pull requests y cambia junto con el código. Cada documento tiene un público y un tipo.
  • En el desarrollo guiado por especificaciones, la especificación es la entrada principal del trabajo con un agente (spec-first, spec-anchored, spec-as-source). Donde la especificación calla, el agente elige; lo que lo hace verificable son pruebas de aceptación escritas o revisadas por una persona.

Ejercicios

  1. Escriban la precondición y la postcondición de isCancellableAt(slot, now) de Kallpa (src/domain/booking.ts). ¿Es total o parcial? ¿Qué hace con un slot de una fecha que ya pasó? ¿Lo dice su contrato?

  2. Escriban la precondición y la postcondición de formatSoles(cents) (src/domain/pricing.ts) para cada una de las tres versiones de soles.ts. ¿Cuál elegirían para Kallpa y por qué? ¿Cambia la respuesta si formatSoles se usara para mostrar un monto que escribió el cajero en un formulario?

  3. Una función promete “devuelve las reservas confirmadas del día”. Escriban una implementación que cumpla esa frase y esté mal. Reescriban la postcondición para que su implementación ya no la cumpla, y pásenla por la lista de seis preguntas de este capítulo.

  4. dailySummary (src/domain/report.ts) recibe la fecha y las reservas, y filtra por fecha y por estado. Escriban su contrato. ¿Filtrar por fecha es parte de la postcondición o una revisión defensiva? Comparen con la pregunta que se hacen en el laboratorio sobre possibleStartHours.

  5. Agreguen a BookResult (booking-result.ts) una variante { ok: false; code: 'IN_THE_PAST' }. Corran make typecheck y anoten cada lugar donde tsc se queja. Arréglenlos. ¿Cuántos habrían encontrado sin assertNever?

  6. Escriban parseBookingStatus(value: string): BookingStatus usando BOOKING_STATUSES, con su contrato. Muestren, en una prueba, qué pasa con 'canceled'. Expliquen qué ganaría toBookingJson usándola en lugar del as BookingStatus actual (no modifiquen Kallpa: escríbanlo en su carpeta de ejercicios).

  7. Definan un tipo marcado Hour para las horas de inicio de Kallpa (de 7 a 21) con su función que parsea. ¿Qué garantiza la marca y qué no? ¿Qué cambiaría en la firma de overlaps si recibiera Hour? ¿Valdría la pena?

  8. Tomen una historia de usuario de su backlog de la semana 3 (o de P1) que no sea trivial y escriban un mini design doc de una o dos páginas, con al menos dos alternativas consideradas y un no objetivo que alguien del equipo hubiera dado por objetivo. Pídanle a otro grupo que lo revise y anoten qué cambiarían.

  9. Para el design doc de turnos fijos de este capítulo, escriban una tercera alternativa distinta de las dos que tiene, con sus trade-offs. ¿Bajo qué respuesta de la administración sería la mejor de las tres?

  10. Clasifiquen cada sección del README de Kallpa según los tipos de documento de SWEG (referencia, design doc, tutorial, conceptual, página de entrada). ¿Hay alguna sección que mezcle tipos? ¿Qué separarían?

  11. Escriban una especificación de una línea para una función de Kallpa que no exista todavía (por ejemplo, “el total de horas reservadas por un vecino en el mes”). Listen al menos cuatro decisiones que esa línea deja abiertas. Si tienen una herramienta de IA, denle solo esa línea y la firma, y anoten cuáles de esas decisiones tomó y cómo. Después escriban tres pruebas de aceptación que la habrían obligado a decidir como ustedes querían.

  12. Böckeler compara spec-as-source con model-driven development. Expliquen con sus palabras qué se gana y qué se pierde al pasar de una especificación en un lenguaje formal (que una herramienta puede revisar) a una en lenguaje natural (que un modelo interpreta). ¿Qué parte de esa pérdida recuperan las pruebas de aceptación y qué parte no?