Semana 4 — Especificaciones y design docs
Objetivos
- Especificar una función con su contrato: precondiciones, postcondiciones e invariantes, y decidir quién es responsable de cada una.
- Usar el sistema de tipos de TypeScript como especificación que se revisa sola (uniones de literales, uniones discriminadas, “parse, don’t validate”) y reconocer lo que los tipos no pueden decir.
- Escribir un design doc con contexto, objetivos y no objetivos, el diseño, las alternativas consideradas y los temas transversales.
- Explicar el desarrollo guiado por especificaciones (spec-driven development) con agentes de IA: qué gana, qué cuesta y cómo se comprueba que el código cumple la especificación.
Sesión A — Especificaciones: contratos, invariantes y tipos
Lecturas:
- Bertrand Meyer, Applying “Design by Contract”, IEEE Computer, 1992.
- Alexis King, Parse, don’t validate (2019).
- TypeScript Handbook, Narrowing (hasta Discriminated unions y Exhaustiveness checking).
Lecturas complementarias:
- TypeScript Handbook, Everyday Types (uniones y tipos literales).
Temas:
- Una especificación dice qué hace un módulo sin decir cómo. Sirve de acuerdo entre quien llama y quien implementa: cualquiera de los dos puede cambiar su código mientras respete el acuerdo. Sin especificación, el único acuerdo es el código, y entonces cada detalle observable termina siendo parte de él (la ley de Hyrum de la semana 1).
- Contrato (Meyer, design by contract). La precondición es lo que quien llama tiene que garantizar; la postcondición es lo que la función garantiza si se cumplió la precondición; el invariante es lo que vale siempre entre operaciones. En Kallpa,
overlaps(a, b)tiene como precondición que los dos bloques sean del mismo día y la misma cancha, y como postcondición que el resultado estruesi y solo si los bloques comparten al menos una hora. Un invariante del sistema: una cancha no tiene dos reservas confirmadas que se crucen. - Precondición débil o fuerte. Si la función revisa todo y devuelve un error, la precondición es débil y la función es total; si exige y no revisa, la precondición es fuerte y la función es parcial.
checkBookingRuleses total: para cualquier bloque devuelve una regla incumplida onull. Las dos opciones son válidas, pero la especificación tiene que decir cuál se eligió. Error común: revisar la misma precondición en tres capas “por si acaso”, o en ninguna porque cada capa cree que la revisa otra. - Postcondiciones completas. “Devuelve horas libres” no es lo mismo que “devuelve todas las horas libres, sin repetir, de menor a mayor”. Una función que siempre devuelve
[]cumple la primera. En las pruebas pasa lo mismo: verificar solo que lo devuelto es correcto no detecta lo que falta. - Los tipos como especificación que revisa
tsc. Uniones de literales en lugar destring(BookingStatus = 'confirmed' | 'cancelled'); uniones discriminadas para resultados que pueden fallar:{ ok: true; booking: Booking } | { ok: false; code: 'SLOT_TAKEN' }, conswitchexhaustivo (never) para que agregar un caso rompa la compilación donde falta manejarlo. Objetosas consten lugar deenum, porque Node solo quita los tipos. - Parse, don’t validate: en la frontera del sistema se convierte el dato sin tipo (
unknown, un JSON) en un tipo que lleva la garantía, una sola vez. En Kallpa,parseBookingRequestrecibeunknowny devuelve unBookingRequesto lanza un error; el resto del código ya no vuelve a revisar la forma. Error común: usaraspara “convencer” al compilador.x as Bookingno revisa nada: es una afirmación que nadie comprueba. - Lo que los tipos no dicen: “de 1 a 3 horas”, “entre las 7:00 y las 22:00”, “no se cruza con otra reserva”. Eso va en la especificación escrita, en revisiones en tiempo de ejecución y en pruebas. Los tipos marcados (branded types, como
type Cents = number & { readonly brand: 'Cents' }) ayudan a no mezclar céntimos con soles, pero no garantizan que el número sea entero.
Sesión B — Design docs y desarrollo guiado por especificaciones
Lecturas:
- Malte Ubl, Design Docs at Google (2020).
- SWEG, cap. 10, Documentation.
- Birgitta Böckeler, Understanding Spec-Driven-Development: Kiro, spec-kit, and Tessl (octubre de 2025).
Lecturas complementarias:
- Stanford, CS146S The Modern Software Developer: la parte de desarrollo guiado por especificaciones con agentes.
Temas:
- Un design doc es un documento informal que se escribe antes de construir algo que no es trivial, para pensar el problema y recoger críticas cuando cambiar todavía es barato. Secciones habituales: contexto y alcance, objetivos y no objetivos (lo que se decide no hacer), el diseño (con diagramas, APIs y datos, sin pegar el código), las alternativas consideradas y por qué se descartaron, y los temas transversales (seguridad, privacidad, observabilidad).
- Las alternativas son la parte más útil. Un design doc que no muestra otra opción no deja ver los trade-offs. Para turnos fijos en Kallpa: guardar una regla de repetición y calcular las fechas al vuelo, o crear de una vez las reservas de toda la temporada. La segunda es más simple de consultar y la primera, más simple de cambiar.
- Cuándo no escribirlo: si la solución es obvia y no hay trade-offs, el documento es un manual de implementación que nadie va a leer. Error común: escribirlo después de construir, para cumplir. Ahí ya no puede cambiar ninguna decisión.
- La documentación como código (cap. 10 de SWEG): tiene owner, vive en el repositorio junto al código, se revisa en los pull requests y se actualiza cuando cambia lo que describe. Cada documento tiene un público y un tipo (referencia, design doc, tutorial, conceptual) y no mezcla tipos.
- Desarrollo guiado por especificaciones (spec-driven development): con agentes de IA, la especificación pasa a ser la entrada principal del trabajo. Se escriben los requisitos y el diseño, el agente propone un plan y tareas, y después el código. Böckeler distingue tres niveles: spec-first (la especificación se escribe antes y se usa para esa tarea), spec-anchored (se conserva para mantener la funcionalidad) y spec-as-source (solo se edita la especificación y el código se regenera).
- Lo que gana: el agente trabaja con un acuerdo explícito en lugar de adivinar, y la especificación se puede revisar antes de que exista el código. Lo que cuesta: documentos largos que alguien tiene que leer de verdad, y agentes que no siempre siguen lo que dice la especificación. Una especificación no comprueba nada sola: hacen falta pruebas de aceptación derivadas de ella, escritas o revisadas por una persona.
- 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.
Laboratorio — De la especificación al código, con y sin un agente
Guía: el pedido de la administración y el template de especificación están en code/week-04/horas-posibles.md. Referencias: Meyer (arriba); TypeScript Handbook, Narrowing.
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. En parejas, sobre su copia de Kallpa (en una rama nueva).
- Leer el pedido y anotar en una lista lo que no dice: ¿cuentan las reservas canceladas?, ¿y las horas de hoy que ya pasaron?, ¿qué pasa si
hourses 0 o 4?, ¿en qué orden se devuelven las horas? - Escribir la especificación en
docs/specs/horas-posibles.mdcon el template: la firma en TypeScript, las precondiciones (y si la función las revisa o las exige), las postcondiciones (todas las horas que cumplen, y solo esas, sin repetir y en orden), el invariante del que depende (no hay reservas confirmadas cruzadas) y una tabla con al menos 6 ejemplos de entrada y salida, incluidos los bordes. Otra pareja revisa la especificación y marca lo ambiguo. - Crear
src/domain/possible-start-hours.tscon la firma que pide el pedido, un comentario que resuma el contrato y un cuerpothrow new Error('sin implementar'). Comprobar quetsc -p .pasa. - Media clase implementa la función a mano a partir de su especificación, usando lo que ya existe en
src/domain/booking.ts(overlaps,checkBookingRules,OPENING_HOUR,CLOSING_HOUR). La otra media le da solo la especificación y la firma a la herramienta de IA que tengan y le pide la implementación. Quien no tenga una herramienta trabaja en pareja con alguien que sí tenga. - Escribir sus propias pruebas de la tabla de ejemplos en
test/possible-start-hours.test.tsy correrlas connode --test test/possible-start-hours.test.ts. - Al final, el docente publica las pruebas de aceptación de la administración (
code/week-04/solution/possible-start-hours.test.ts). Copiarlas atest/con otro nombre y correrlas. Por cada prueba que falle, decidir si el error está en el código o en la especificación (un caso que no se previó, o que se previó distinto). Corregir primero la especificación y después el código, hasta quemake checkpase. - Comparar entre parejas: ¿quién terminó antes, a mano o con el agente? ¿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 de IA, anotar en
docs/registro-ia.mdqué se le pidió, qué se aceptó, qué se cambió y por qué.
Entregas
- No hay entregas esta semana. P1 (requisitos) se entrega en la semana 5 (
projects/p1-requisitos/).
Preguntas de repaso
- Escriban la precondición y la postcondición de
formatSoles(cents)de Kallpa (src/domain/pricing.ts). ¿Qué debería pasar con-50o con12.5? ¿La dejarían total o parcial? - Una función promete “devuelve las reservas confirmadas del día”. Escriban una implementación que cumpla esa frase y esté mal. ¿Qué le falta a la especificación?
- ¿Qué gana Kallpa con
BookingStatus = 'confirmed' | 'cancelled'en lugar destring? ¿Qué pasa con esa garantía si en algún lugar se escribevalor as BookingStatus? - ¿Por qué la sección de alternativas consideradas es la más útil de un design doc? Den dos alternativas para guardar los turnos fijos de la semana 3.
- Un agente implementa una función a partir de su especificación y todas las pruebas que generó pasan. ¿Qué tendría que ser cierto para que confíen en ese código?
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (10)Errores (7)Pruebas (9)Compilación (3)
Ejemplos
Los programas que usan los apuntes y la clase.
// El resultado de intentar una reserva, como unión discriminada: el campo
// `ok` dice cuál de las formas es, y tsc solo deja leer `booking` cuando
// ya se comprobó que `ok` es true.
import type { Booking } from '../../kallpa/src/domain/booking.ts';
export type BookResult =
| { ok: true; booking: Booking }
| { ok: false; code: 'SLOT_TAKEN' }
| { ok: false; code: 'OUTSIDE_OPENING_HOURS'; open: number; close: number };
// 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);
}
}
export function describeResult(result: BookResult): string {
if (result.ok) {
// Aquí tsc ya sabe que result es { ok: true; booking: Booking }.
return `Reserva ${result.booking.id} confirmada para ${result.booking.teamName}.`;
}
switch (result.code) {
case 'SLOT_TAKEN':
return 'La cancha ya está reservada en ese horario.';
case 'OUTSIDE_OPENING_HOURS':
// Y aquí, que tiene open y close.
return `El complejo atiende de ${result.open}:00 a ${result.close}:00.`;
default:
return assertNever(result);
}
}// Un tipo marcado (branded type): para tsc, Cents es un number que además
// lleva una marca que ningún number común tiene. La marca solo existe en los
// tipos; en tiempo de ejecución, un Cents es un number y nada más.
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;
}
export function toSoles(value: number): Soles {
if (!Number.isFinite(value)) {
throw new RangeError(`Un monto en soles es un número finito y llegó ${value}.`);
}
return value as Soles;
}
// Pasar de soles a céntimos redondea al céntimo más cercano.
export function solesToCents(soles: Soles): Cents {
return toCents(Math.round(soles * 100));
}// Reservas de prueba: solo se escribe lo que importa en cada caso.
import type { Booking } from '../../kallpa/src/domain/booking.ts';
import type { LimaTime } from '../../kallpa/src/domain/clock.ts';
// Lunes 9 de marzo de 2026 a las 10:30, hora de Lima.
export const MONDAY_1030: LimaTime = { date: '2026-03-09', hour: 10, minute: 30 };
export const TUESDAY = '2026-03-10';
let nextId = 1;
export function booking(fields: Partial<Booking> & Pick<Booking, 'startHour' | 'hours'>): Booking {
return {
id: nextId++,
courtId: 1,
memberId: 1,
date: TUESDAY,
teamName: 'Los Pumas de Comas',
priceCents: 9000 * fields.hours,
status: 'confirmed',
createdAt: '2026-03-08T15:00:00.000Z',
cancelledAt: null,
...fields,
};
}// "Devuelve las horas libres de una cancha en un día": tres funciones que
// cumplen esa frase, y una sola que cumple la postcondición completa.
import { CLOSING_HOUR, OPENING_HOUR, overlaps, type Booking } from '../../kallpa/src/domain/booking.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[] {
const result: number[] = [];
for (let hour = OPENING_HOUR; hour < CLOSING_HOUR; hour++) {
const taken = bookings.some((b) => b.status === 'confirmed' && hour >= b.startHour && hour < b.startHour + b.hours);
if (!taken) result.push(hour);
}
return result;
}
// Cumple "devuelve horas libres": todo lo que devuelve está libre.
export function freeHoursNone(_bookings: readonly Booking[]): number[] {
return [];
}
// También: la primera hora libre está libre.
export function freeHoursFirstOnly(bookings: readonly Booking[]): number[] {
return freeHours(bookings).slice(0, 1);
}
// Revisa la postcondición completa. La lista esperada se calcula de otra
// forma (con overlaps, hora por hora) para no usar la función que se prueba
// como su propio oráculo. Devuelve las diferencias en palabras; una lista
// vacía quiere decir que se cumple.
export function postconditionViolations(result: readonly number[], bookings: readonly Booking[]): string[] {
const problems: string[] = [];
const confirmed = bookings.filter((b) => b.status === 'confirmed');
const expected: number[] = [];
for (let hour = OPENING_HOUR; hour < CLOSING_HOUR; hour++) {
if (!confirmed.some((b) => overlaps({ startHour: hour, hours: 1 }, b))) expected.push(hour);
}
for (const hour of expected) {
if (!result.includes(hour)) problems.push(`falta la hora ${hour}`);
}
for (const hour of result) {
if (!expected.includes(hour)) problems.push(`sobra la hora ${hour}`);
}
for (let i = 1; i < result.length; i++) {
if (result[i - 1] >= result[i]) problems.push(`fuera de orden o repetida en la posición ${i}`);
}
return problems;
}# Horas posibles: a qué hora puede empezar una reserva de N horas
Laboratorio de la semana 4. Primero el pedido tal como llegó; después, lo que
respondió la administración a las preguntas de otro grupo; al final, el
template de la especificación que van a copiar en `docs/specs/horas-posibles.md`
de su copia de Kallpa.
## 1. El pedido
Mensaje de Doris Chumpitaz, de la administración del complejo:
> La página muestra hora por hora qué está libre, y eso está bien para el que
> quiere una hora. Pero el que quiere dos o tres horas tiene que ir mirando
> una por una dónde le caben, y muchos se equivocan: eligen las 20:00 para
> dos horas y el sistema les dice que no, porque a las 21:00 ya hay alguien.
> Queremos que, cuando el vecino elija cuántas horas quiere, la página le
> muestre directamente a qué horas puede empezar.
>
> Me dicen que lo primero es una función que, con las reservas de esa cancha
> para ese día, la fecha, cuántas horas quiere y la hora de ahora, diga las
> horas de inicio posibles. Después se conecta con la página.
La función va en `src/domain/possible-start-hours.ts`, con esta firma:
```ts
import type { Booking } from './booking.ts';
import type { LimaTime } from './clock.ts';
export function possibleStartHours(
bookings: readonly Booking[],
date: string,
hours: number,
now: LimaTime,
): number[];
```
`bookings` son las reservas que devuelve el sistema para esa cancha y ese día
(`BookingService.bookingsForCourt`, convertidas con `toBookingJson`), con las
canceladas incluidas. `date` es la fecha elegida (`'YYYY-MM-DD'`), `hours` la
cantidad de horas y `now` la hora de Lima (`limaTime(clock.now())`).
**Antes de seguir leyendo, hagan el paso 1 de la guía**: la lista de lo que
el pedido no dice.
## 2. Lo que respondió la administración
Otro grupo le hizo a Doris estas preguntas. Sus respuestas, con sus palabras:
- **¿Cuentan las reservas canceladas?** "No. Si una reserva se canceló, esa
hora está libre otra vez, aunque la reserva siga apareciendo en la lista."
- **¿Y si la fecha es hoy?** "Las horas que ya empezaron no se ofrecen, igual
que al reservar. Si son las 10:30, la de las 10:00 ya empezó. Si son las
10:00 en punto, la de las 10:00 también: ya no se puede reservar."
- **¿En qué orden?** "De la más temprana a la más tarde, cada hora una sola
vez. Así las vamos a mostrar."
- **¿Y si `hours` es 0, o 4, o 2.5?** "La página solo deja elegir 1, 2 o 3.
No sé qué debería pasar si llega otra cosa. Decidan ustedes, pero
escríbanlo."
- **¿Y si la fecha ya pasó, o es de aquí a un mes?** "Tampoco se puede
elegir en la página: el calendario solo muestra de hoy a dos semanas.
Lo mismo: decidan y escríbanlo."
Las reglas del complejo están en el [README de Kallpa](../../kallpa/README.md#reglas-del-negocio):
horario de 7:00 a 22:00, reservas de 1 a 3 horas seguidas, una cancha no
tiene dos reservas confirmadas que se crucen.
Las dos últimas respuestas dejan una decisión abierta: ¿la función **revisa**
esos casos (y entonces dice qué devuelve o qué lanza), o los **exige** como
precondición (y entonces quien la llama los garantiza y la función no promete
nada si no se cumplen)? Las dos opciones son válidas. Lo que no vale es no
decidir.
## 3. Template de la especificación
Copien esto en `docs/specs/horas-posibles.md` y llénenlo. Donde dice
*decidir*, escriban la decisión y el porqué en una línea.
```md
# Especificación: possibleStartHours
Autores: ... Revisado por (otra pareja): ... Fecha: ...
## Propósito
Una o dos frases: qué responde la función y para quién.
## Firma
(la firma en TypeScript, copiada del pedido)
## Precondiciones
Lo que quien llama tiene que garantizar. Para cada una: ¿la función la
revisa (y qué hace si no se cumple) o la exige (y no promete nada)?
- `bookings`: ...
- `date`: ... (decidir: ¿qué pasa con una fecha que ya pasó o fuera de las
dos semanas?)
- `hours`: ... (decidir: ¿qué pasa con 0, 4 o 2.5?)
- `now`: ...
## Postcondiciones
Lo que la función garantiza si se cumplen las precondiciones. Escríbanlo de
forma que una función que devuelve siempre `[]` NO lo cumpla.
- Toda hora devuelta ...
- Toda hora que ... aparece en el resultado.
- Orden y repetidos: ...
## Invariante del que depende
Qué propiedad del sistema da por cierta la función (y qué pasaría si no
se cumple).
## Ejemplos
Al menos 6, incluidos los bordes. Hoy es lunes 9 de marzo de 2026.
| # | Reservas de la cancha ese día | date | hours | now | Resultado |
|---|---|---|---|---|---|
| 1 | ninguna | 2026-03-10 | 1 | 2026-03-09 10:30 | 7, 8, ..., 21 |
| 2 | ... | ... | ... | ... | ... |
## Lo que la función no hace
Por ejemplo: no sabe de precios, no reserva, no revisa ...
## Preguntas abiertas
Lo que todavía no está decidido y a quién hay que preguntarle.
```
## 4. El esqueleto
El paso 3 de la guía pide crear el archivo con la firma, un comentario que
resuma el contrato y este cuerpo:
```ts
throw new Error('sin implementar');
```
`tsc -p .` pasa con el esqueleto. `eslint .` no: marca los cuatro
parámetros como definidos y sin usar (`@typescript-eslint/no-unused-vars`).
Es lo esperado mientras no haya implementación; por eso el paso 3 pide solo
`tsc`, y `make check` completo se corre en el paso 6.
## 5. Para quien use una herramienta de IA (paso 4)
Denle **solo** la especificación y la firma: ni este archivo, ni el código
de Kallpa, ni sus pruebas. Guarden lo que le pidieron y lo que devolvió;
lo van a necesitar en `docs/registro-ia.md` (paso 8) y en la comparación del
paso 7. Antes de aceptar el código, léanlo contra su especificación: cada
decisión que tomó la herramienta donde la especificación calla es una
decisión que nadie revisó.// Validar frente a parsear (Alexis King, "Parse, don't validate").
// Las dos funciones revisan lo mismo; la diferencia está en lo que devuelven.
import { MAX_HOURS_PER_BOOKING } from '../../kallpa/src/domain/booking.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;
}// El invariante del sistema: una cancha no tiene dos reservas confirmadas que
// se crucen. Esta función no lo hace cumplir; dice si se cumple, para usarla
// en pruebas y en revisiones de los datos.
import { overlaps, type Booking } from '../../kallpa/src/domain/booking.ts';
export function overlappingPairs(bookings: readonly Booking[]): [Booking, Booking][] {
const confirmed = bookings.filter((b) => b.status === 'confirmed');
const pairs: [Booking, Booking][] = [];
for (let i = 0; i < confirmed.length; i++) {
for (let j = i + 1; j < confirmed.length; j++) {
const a = confirmed[i];
const b = confirmed[j];
// overlaps solo tiene sentido con la misma cancha y el mismo día: es
// su precondición, y aquí la cumple quien la llama.
if (a.courtId === b.courtId && a.date === b.date && overlaps(a, b)) pairs.push([a, b]);
}
}
return pairs;
}// Tres contratos para la misma tarea: mostrar céntimos como soles.
// formatSoles de Kallpa no dice qué pasa con -50 o con 12.5; aquí cada
// versión lo dice de una forma distinta.
import { formatSoles } from '../../kallpa/src/domain/pricing.ts';
// 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) };
}// Los casos de aceptación de upcomingBookings, derivados de la especificación
// completa (ver upcoming.ts). Cada caso dice qué ids espera y en qué orden.
import type { Booking } from '../../kallpa/src/domain/booking.ts';
import type { LimaTime } from '../../kallpa/src/domain/clock.ts';
import { booking, MONDAY_1030 } from './fixtures.ts';
export type AcceptanceCase = { name: string; bookings: Booking[]; now: LimaTime; expectedIds: number[] };
export const acceptanceCases: AcceptanceCase[] = [
{
name: 'devuelve las reservas futuras ordenadas por fecha',
bookings: [
booking({ id: 1, date: '2026-03-12', startHour: 19, hours: 1 }),
booking({ id: 2, date: '2026-03-10', startHour: 18, hours: 2 }),
],
now: MONDAY_1030,
expectedIds: [2, 1],
},
{
name: 'no devuelve las reservas canceladas',
bookings: [
booking({ id: 1, date: '2026-03-10', startHour: 18, hours: 1, status: 'cancelled' }),
booking({ id: 2, date: '2026-03-11', startHour: 18, hours: 1 }),
],
now: MONDAY_1030,
expectedIds: [2],
},
{
name: 'no devuelve una reserva de hoy que ya empezó',
bookings: [
booking({ id: 1, date: '2026-03-09', startHour: 10, hours: 2 }),
booking({ id: 2, date: '2026-03-09', startHour: 11, hours: 1 }),
],
now: MONDAY_1030,
expectedIds: [2],
},
{
name: 'en la misma fecha, ordena por hora de inicio',
bookings: [
booking({ id: 1, date: '2026-03-10', startHour: 20, hours: 1 }),
booking({ id: 2, date: '2026-03-10', startHour: 8, hours: 1 }),
],
now: MONDAY_1030,
expectedIds: [2, 1],
},
];// "Las próximas reservas de un vecino", para mostrarlas en su página.
//
// La primera versión de la especificación decía solo eso. La versión
// completa, después de preguntarle a la administración, dice:
//
// upcomingBookings(bookings, now)
// Precondición: `bookings` son las reservas de un solo vecino (todas sus
// reservas, de cualquier fecha y estado).
// Postcondición: devuelve las reservas confirmadas que todavía no
// empiezan, y solo esas, ordenadas por fecha y, en la misma fecha, por
// hora de inicio. Una reserva de hoy que empieza a las 19:00 ya empezó a
// las 19:00 en punto.
import type { Booking } from '../../kallpa/src/domain/booking.ts';
import type { LimaTime } from '../../kallpa/src/domain/clock.ts';
function hasStarted(b: Booking, now: LimaTime): boolean {
return b.date < now.date || (b.date === now.date && b.startHour <= now.hour);
}
export function upcomingBookings(bookings: readonly Booking[], now: LimaTime): Booking[] {
return bookings
.filter((b) => b.status === 'confirmed' && !hasStarted(b, now))
.sort((a, b) => a.date.localeCompare(b.date) || a.startHour - b.startHour);
}
// Una implementación que cumple la primera frase ("las próximas reservas")
// y no la especificación completa. Está escrita para la clase, como
// ilustración de lo que suele devolver un agente cuando la especificación
// calla: cada decisión es razonable por separado, y ninguna se anuncia.
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
}Errores
Ejemplos con errores a propósito: no compilan, fallan o dan un resultado incorrecto. Los apuntes explican cada uno.
// Mezclar céntimos con soles, o con un number cualquiera.
import { addCents, toCents, toSoles } from '../cents.ts';
const price = toCents(9000);
addCents(price, toSoles(112.5));
addCents(price, 11250);// No compila con erasableSyntaxOnly, y Node tampoco lo ejecuta: un enum
// genera código, y Node solo quita los tipos.
enum Status {
Confirmed,
Cancelled,
}
console.log(Status.Confirmed);// Validar no le cuenta nada a tsc; parsear, sí.
import { endHour, isValidHours, parseHours } from '../hours.ts';
const body: unknown = JSON.parse('{"hours": 2}');
const hours = (body as { hours: unknown }).hours;
if (isValidHours(hours)) {
endHour(19, hours);
}
endHour(19, parseHours(hours));// Se agrega un caso a la unión y no se agrega al switch: tsc marca el
// lugar exacto que falta actualizar.
import type { Booking } from '../../../kallpa/src/domain/booking.ts';
import { assertNever } from '../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: false; code: 'TOO_FAR_AHEAD'; maxDays: number };
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);
}
}// Leer `booking` sin haber mirado `ok`.
import type { BookResult } from '../booking-result.ts';
export function teamName(result: BookResult): string {
return result.booking.teamName;
}{
"extends": "../tsconfig.json",
"include": ["*.ts"]
}// Con BookingStatus, un error de ortografía en el estado no compila.
import type { Booking } from '../../../kallpa/src/domain/booking.ts';
export function isCancelled(b: Booking): boolean {
return b.status === 'canceled';
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
// Las demostraciones de demos/ fallan a propósito; aquí se comprueba que
// fallan como dice el capítulo.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { test } from 'node:test';
// Un node --test dentro de otro hereda NODE_TEST_CONTEXT y cambia su forma
// de informar; sin esa variable se comporta como desde la terminal.
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
test('la versión que adivina falla 3 de las 4 pruebas de aceptación', () => {
const result = spawnSync('node', ['--test', 'demos/upcoming-guess.test.ts'], { encoding: 'utf8', env });
assert.equal(result.status, 1);
assert.match(result.stdout, /ℹ pass 1\n/);
assert.match(result.stdout, /ℹ fail 3\n/);
});// Una prueba que solo mira que lo devuelto sea correcto no ve lo que falta.
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { booking } from './fixtures.ts';
import { freeHours, freeHoursFirstOnly, freeHoursNone, postconditionViolations } from './free-hours.ts';
// Martes: Real Barranco de 18:00 a 20:00; la reserva de las 9:00 se canceló.
const tuesday = [
booking({ startHour: 18, hours: 2, teamName: 'Real Barranco' }),
booking({ startHour: 9, hours: 1, status: 'cancelled', cancelledAt: '2026-03-09T14:00:00.000Z' }),
];
const implementations = { freeHours, freeHoursNone, freeHoursFirstOnly };
describe('la prueba débil: "todo lo que devuelve está libre"', () => {
for (const [name, impl] of Object.entries(implementations)) {
test(`${name} la pasa`, () => {
for (const hour of impl(tuesday)) {
assert.ok(hour !== 18 && hour !== 19, `la hora ${hour} está ocupada`);
}
});
}
});
describe('la postcondición completa', () => {
test('freeHours la cumple', () => {
assert.deepEqual(postconditionViolations(freeHours(tuesday), tuesday), []);
assert.deepEqual(freeHours(tuesday), [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 20, 21]);
});
test('freeHoursNone no: le faltan las 13 horas libres', () => {
assert.equal(postconditionViolations(freeHoursNone(tuesday), tuesday).length, 13);
});
test('freeHoursFirstOnly no: le faltan 12', () => {
const problems = postconditionViolations(freeHoursFirstOnly(tuesday), tuesday);
assert.equal(problems.length, 12);
assert.equal(problems[0], 'falta la hora 8');
});
test('el revisor también ve horas repetidas o desordenadas', () => {
assert.deepEqual(postconditionViolations([7, 9, 8, 10, 11, 12, 13, 14, 15, 16, 17, 20, 21, 21], tuesday), [
'fuera de orden o repetida en la posición 2',
'fuera de orden o repetida en la posición 13',
]);
});
});// El invariante "una cancha no tiene dos reservas confirmadas que se crucen"
// lo mantiene el handler de POST /api/bookings, no la base de datos.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { addDays } from '../../kallpa/src/domain/calendar.ts';
import { toBookingJson } from '../../kallpa/src/http/handlers.ts';
import { openDatabase } from '../../kallpa/src/store/database.ts';
import { BookingRepository } from '../../kallpa/src/store/booking-repository.ts';
import { seedDatabase } from '../../kallpa/src/store/seed.ts';
import { overlappingPairs } from './invariant.ts';
const TODAY = '2026-03-09';
function allBookings(repo: BookingRepository, from: string, days: number) {
const rows = [];
for (let d = 0; d < days; d++) rows.push(...repo.listByDate(addDays(from, d)));
return rows.map(toBookingJson);
}
test('los datos de ejemplo cumplen el invariante', () => {
const db = openDatabase(':memory:');
seedDatabase(db, TODAY);
const repo = new BookingRepository(db);
const bookings = allBookings(repo, addDays(TODAY, -3), 10);
assert.equal(bookings.length, 15);
assert.deepEqual(overlappingPairs(bookings), []);
db.close();
});
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,
team_name: 'Fulbito de las 7',
price_cents: 11250,
created_at: '2026-03-09T15:00:00.000Z',
});
const pairs = overlappingPairs(allBookings(repo, addDays(TODAY, -3), 10));
assert.equal(pairs.length, 1);
assert.deepEqual(
pairs[0].map((b) => `${b.teamName} ${b.startHour}-${b.startHour + b.hours}`),
['Real Barranco 19-21', 'Fulbito de las 7 20-21'],
);
db.close();
});// El contrato de overlaps (src/domain/booking.ts de Kallpa).
// Precondición: los dos bloques son de la misma cancha y el mismo día.
// Postcondición: true si y solo si los bloques comparten al menos una hora.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { CLOSING_HOUR, MAX_HOURS_PER_BOOKING, OPENING_HOUR, overlaps } from '../../kallpa/src/domain/booking.ts';
type Block = { startHour: number; hours: number };
// Todos los bloques que se pueden reservar en un día: 15 + 14 + 13 = 42.
const blocks: Block[] = [];
for (let hours = 1; hours <= MAX_HOURS_PER_BOOKING; hours++) {
for (let startHour = OPENING_HOUR; startHour + hours <= CLOSING_HOUR; startHour++) {
blocks.push({ startHour, hours });
}
}
// 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)}`);
}
}
});
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);
});import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { formatSoles } from '../../kallpa/src/domain/pricing.ts';
import { formatSolesChecked, formatSolesResult } from './soles.ts';
describe('formatSoles de Kallpa, fuera de lo que se pensó', () => {
test('con enteros >= 0 hace lo que dice su comentario', () => {
assert.equal(formatSoles(11250), 'S/ 112.50');
assert.equal(formatSoles(0), 'S/ 0.00');
});
test('con -50 o con medio céntimo devuelve un texto que parece correcto', () => {
assert.equal(formatSoles(-50), 'S/ -0.50');
assert.equal(formatSoles(12.5), 'S/ 0.13');
assert.equal(formatSoles(0.5), 'S/ 0.01');
assert.equal(formatSoles(Number.NaN), 'S/ NaN');
});
});
describe('precondición revisada: RangeError', () => {
test('rechaza lo que no es un entero >= 0', () => {
assert.throws(() => formatSolesChecked(-50), RangeError);
assert.throws(() => formatSolesChecked(12.5), RangeError);
assert.throws(() => formatSolesChecked(Number.NaN), RangeError);
});
test('con un entero >= 0 responde lo mismo que formatSoles', () => {
assert.equal(formatSolesChecked(11250), 'S/ 112.50');
});
});
describe('total, con el error en el tipo', () => {
test('dice qué falló sin lanzar nada', () => {
assert.deepEqual(formatSolesResult(-50), { ok: false, code: 'NEGATIVE' });
assert.deepEqual(formatSolesResult(12.5), { ok: false, code: 'NOT_AN_INTEGER' });
assert.deepEqual(formatSolesResult(11250), { ok: true, text: 'S/ 112.50' });
});
});// Lo que el capítulo dice de tsc, comprobado con tsc: los archivos de bad/
// tienen que fallar con estos errores, y el resto tiene que compilar.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { test } from 'node:test';
function run(command: string, args: string[]) {
const result = spawnSync(command, args, { encoding: 'utf8' });
return { status: result.status, output: result.stdout + result.stderr };
}
test('el código del capítulo compila con tsc', () => {
const { status, output } = run('tsc', ['-p', '.']);
assert.equal(status, 0, output);
});
test('cada archivo de bad/ falla donde el capítulo dice', () => {
const { status, output } = run('tsc', ['-p', 'bad', '--pretty', 'false']);
assert.notEqual(status, 0);
const errors = output
.split('\n')
.filter((line) => line.startsWith('bad/'))
.map((line) => line.replace(/^(bad\/[\w-]+\.ts)\((\d+),\d+\): error (TS\d+):.*$/, '$1:$2 $3'));
assert.deepEqual(errors, [
'bad/brands.ts:5 TS2345', // Soles donde se pide Cents
'bad/brands.ts:6 TS2345', // number donde se pide Cents
'bad/enum.ts:3 TS1294', // enum con erasableSyntaxOnly
'bad/hours.ts:8 TS2345', // validar no cambia el tipo: sigue siendo unknown
'bad/missing-case.ts:20 TS2345', // el caso nuevo llega a assertNever
'bad/no-narrowing.ts:5 TS2339', // booking sin mirar ok
'bad/typo-status.ts:5 TS2367', // 'canceled' no es un BookingStatus
]);
});
test('Node no ejecuta un enum: solo quita los tipos', () => {
const { status, output } = run('node', ['bad/enum.ts']);
assert.equal(status, 1);
assert.match(output, /ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX/);
});// Lo que los tipos revisan en tiempo de ejecución (nada) y lo que las
// funciones que parsean sí revisan.
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { describeResult, httpStatus, type BookResult } from './booking-result.ts';
import { addCents, solesToCents, toCents, toSoles, type Cents } from './cents.ts';
import { booking } from './fixtures.ts';
import { endHour, isValidHours, parseHours, type BookingHours } from './hours.ts';
describe('unión discriminada', () => {
test('cada forma del resultado tiene su código HTTP y su mensaje', () => {
const results: BookResult[] = [
{ ok: true, booking: booking({ id: 7, startHour: 19, hours: 2, teamName: 'Real Barranco' }) },
{ ok: false, code: 'SLOT_TAKEN' },
{ ok: false, code: 'OUTSIDE_OPENING_HOURS', open: 7, close: 22 },
];
assert.deepEqual(results.map(httpStatus), [200, 409, 422]);
assert.deepEqual(results.map(describeResult), [
'Reserva 7 confirmada para Real Barranco.',
'La cancha ya está reservada en ese horario.',
'El complejo atiende de 7:00 a 22:00.',
]);
});
});
describe('validar frente a parsear', () => {
test('las dos revisan lo mismo', () => {
for (const value of [1, 2, 3, 0, 4, 2.5, '2', null]) {
const parsed = (() => {
try {
parseHours(value);
return true;
} catch {
return false;
}
})();
assert.equal(isValidHours(value), parsed, JSON.stringify(value));
}
});
test('parseHours devuelve el valor con un tipo más preciso, o lanza', () => {
const hours: BookingHours = parseHours(JSON.parse('{"hours": 2}').hours);
assert.equal(endHour(19, hours), 21);
assert.throws(() => parseHours(4), /debe ser 1, 2 o 3/);
});
test('`as` no revisa nada: 7 as BookingHours pasa y da una hora de fin imposible', () => {
assert.equal(endHour(19, 7 as BookingHours), 26);
});
});
describe('tipos marcados', () => {
test('la marca no existe en tiempo de ejecución', () => {
const price = toCents(11250);
assert.equal(typeof price, 'number');
assert.equal(JSON.stringify({ priceCents: price }), '{"priceCents":11250}');
});
test('toCents rechaza decimales; `as Cents` no', () => {
assert.throws(() => toCents(112.5), RangeError);
const sneaky = 112.5 as Cents;
assert.equal(addCents(sneaky, toCents(100)), 212.5);
});
test('de soles a céntimos se redondea al céntimo', () => {
assert.equal(solesToCents(toSoles(112.5)), 11250);
assert.equal(solesToCents(toSoles(0.1 + 0.2)), 30);
});
});// Pruebas de aceptación de la especificación completa de upcomingBookings.
// Corren contra la implementación de upcoming.ts; demos/upcoming-guess.test.ts
// corre las mismas contra la versión que adivina.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { upcomingBookings } from './upcoming.ts';
import { acceptanceCases } from './upcoming-cases.ts';
for (const { name, bookings, now, expectedIds } of acceptanceCases) {
test(name, () => {
assert.deepEqual(
upcomingBookings(bookings, now).map((b) => b.id),
expectedIds,
);
});
}// Las pruebas de aceptación de upcomingBookings contra la versión que adivina.
// Fallan a propósito: make demos las corre y muestra qué decidió esa versión
// donde la primera especificación callaba. make test no las corre.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { upcomingBookingsGuess } from '../upcoming.ts';
import { acceptanceCases } from '../upcoming-cases.ts';
for (const { name, bookings, now, expectedIds } of acceptanceCases) {
test(name, () => {
assert.deepEqual(
upcomingBookingsGuess(bookings, now).map((b) => b.id),
expectedIds,
);
});
}Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 4: especificaciones, contratos, invariantes y tipos; design docs y
# desarrollo guiado por especificaciones. Se usa dentro del contenedor del
# curso (./env/run.sh desde la carpeta del curso).
#
# make copia Kallpa en build/kallpa (sin la base de datos): las
# revisiones del laboratorio trabajan sobre copias, nunca
# sobre kallpa/
# make test corre las pruebas de los ejemplos del capítulo (*.test.ts),
# que importan el código de Kallpa sin modificarlo, y las del
# laboratorio si está la carpeta solution/
# make typecheck revisa los tipos con tsc
# make errores muestra los errores de tsc de los archivos de bad/, que
# fallan a propósito
# make demos corre las pruebas de demos/, que fallan a propósito
# make clean borra build/
#
# El pedido y el template del laboratorio están en horas-posibles.md.
KALLPA ?= ../../kallpa
# Las pruebas de aceptación del laboratorio (possible-start-hours.test.ts)
# importan desde una copia de Kallpa (../src/domain/...): no se corren aquí.
TESTS := $(filter-out possible-start-hours.test.ts,$(wildcard *.test.ts))
.PHONY: all test typecheck errores demos clean
all: build/kallpa/package.json
build/kallpa/package.json: $(wildcard $(KALLPA)/src/*.ts $(KALLPA)/src/*/*.ts $(KALLPA)/test/*.ts $(KALLPA)/*.json $(KALLPA)/*.js $(KALLPA)/Makefile)
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
test: all
node --test $(TESTS)
@if [ -f solution/acceptance.test.ts ]; then \
node --test solution/acceptance.test.ts; \
elif [ -f possible-start-hours.test.ts ] || [ -f solution/possible-start-hours.test.ts ]; then \
echo "(las pruebas de aceptación de possibleStartHours se corren en su copia de Kallpa: ver horas-posibles.md)"; \
else \
echo "(sin solution/: las pruebas de aceptación del laboratorio se publican al final del laboratorio)"; \
fi
typecheck:
tsc -p .
errores:
-tsc -p bad
demos:
-node --test demos/*.test.ts
clean:
rm -rf build{
"name": "week-04",
"private": true,
"type": "module"
}{
"compilerOptions": {
"target": "es2024",
"lib": ["es2024"],
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"],
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["*.ts", "demos/*.ts"],
"exclude": ["possible-start-hours.test.ts"]
}