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:

Lecturas complementarias:

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 es true si 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. checkBookingRules es total: para cualquier bloque devuelve una regla incumplida o null. 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 de string (BookingStatus = 'confirmed' | 'cancelled'); uniones discriminadas para resultados que pueden fallar: { ok: true; booking: Booking } | { ok: false; code: 'SLOT_TAKEN' }, con switch exhaustivo (never) para que agregar un caso rompa la compilación donde falta manejarlo. Objetos as const en lugar de enum, 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, parseBookingRequest recibe unknown y devuelve un BookingRequest o lanza un error; el resto del código ya no vuelve a revisar la forma. Error común: usar as para “convencer” al compilador. x as Booking no 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:

Lecturas complementarias:

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).

  1. 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 hours es 0 o 4?, ¿en qué orden se devuelven las horas?
  2. Escribir la especificación en docs/specs/horas-posibles.md con 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.
  3. Crear src/domain/possible-start-hours.ts con la firma que pide el pedido, un comentario que resuma el contrato y un cuerpo throw new Error('sin implementar'). Comprobar que tsc -p . pasa.
  4. 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.
  5. Escribir sus propias pruebas de la tabla de ejemplos en test/possible-start-hours.test.ts y correrlas con node --test test/possible-start-hours.test.ts.
  6. Al final, el docente publica las pruebas de aceptación de la administración (code/week-04/solution/possible-start-hours.test.ts). Copiarlas a test/ 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 que make check pase.
  7. 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?
  8. Si usaron una herramienta de IA, anotar en docs/registro-ia.md qué se le pidió, qué se aceptó, qué se cambió y por qué.

Entregas

Preguntas de repaso

  1. Escriban la precondición y la postcondición de formatSoles(cents) de Kallpa (src/domain/pricing.ts). ¿Qué debería pasar con -50 o con 12.5? ¿La dejarían total o parcial?
  2. 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?
  3. ¿Qué gana Kallpa con BookingStatus = 'confirmed' | 'cancelled' en lugar de string? ¿Qué pasa con esa garantía si en algún lugar se escribe valor as BookingStatus?
  4. ¿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.
  5. 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.

booking-result.tsDescargar booking-result.ts
// 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);
  }
}
cents.tsDescargar cents.ts
// 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));
}
fixtures.tsDescargar fixtures.ts
// 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,
  };
}
free-hours.tsDescargar free-hours.ts
// "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.mdDescargar horas-posibles.md
# 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ó.
hours.tsDescargar hours.ts
// 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;
}
invariant.tsDescargar invariant.ts
// 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;
}
soles.tsDescargar soles.ts
// 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) };
}
upcoming-cases.tsDescargar upcoming-cases.ts
// 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],
  },
];
upcoming.tsDescargar upcoming.ts
// "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.

bad/brands.tsDescargar bad/brands.ts
// 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);
bad/enum.tsDescargar bad/enum.ts
// 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);
bad/hours.tsDescargar bad/hours.ts
// 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));
bad/missing-case.tsDescargar bad/missing-case.ts
// 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);
  }
}
bad/no-narrowing.tsDescargar bad/no-narrowing.ts
// Leer `booking` sin haber mirado `ok`.
import type { BookResult } from '../booking-result.ts';

export function teamName(result: BookResult): string {
  return result.booking.teamName;
}
bad/tsconfig.jsonDescargar bad/tsconfig.json
{
  "extends": "../tsconfig.json",
  "include": ["*.ts"]
}
bad/typo-status.tsDescargar bad/typo-status.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.

demos.test.tsDescargar demos.test.ts
// 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/);
});
free-hours.test.tsDescargar free-hours.test.ts
// 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',
    ]);
  });
});
invariant.test.tsDescargar invariant.test.ts
// 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();
});
overlaps.test.tsDescargar overlaps.test.ts
// 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);
});
soles.test.tsDescargar soles.test.ts
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' });
  });
});
typecheck.test.tsDescargar typecheck.test.ts
// 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/);
});
types.test.tsDescargar types.test.ts
// 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);
  });
});
upcoming.test.tsDescargar upcoming.test.ts
// 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,
    );
  });
}
demos/upcoming-guess.test.tsDescargar demos/upcoming-guess.test.ts
// 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.

MakefileDescargar Makefile
# 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
package.jsonDescargar package.json
{
  "name": "week-04",
  "private": true,
  "type": "module"
}
tsconfig.jsonDescargar tsconfig.json
{
  "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"]
}