Bij Bronscode B.V. bouwen we dagelijks webapplicaties die draaien in omgevingen waar betrouwbaarheid en correctheid van groot belang zijn. In die context geloven wij sterk in de kracht van types.
Die overtuiging komt niet uit de lucht vallen. Ik studeerde wiskunde, informatica en filosofie en publiceerde een paper over type-theorie. Deze formele basis komt terug in alle software die we bouwen en vormt het fundament van ons framework Eidos.
Zo af en toe kom je een casus tegen waarbij onze manier van werken met types mooi naar voren komt. In dit artikel laten we zien hoe slimme types de correctheid van statustransities met het typesysteem van TypeScript kunnen controleren.
Context
Voor een project moeten berichten geplaatst kunnen worden. Berichten worden na 30 seconden automatisch gepubliceerd. Het is ook mogelijk een concept te maken dat nog niet gepubliceerd wordt, of een gepubliceerd bericht terug te trekken.
De statussen zijn draft, pending, published en retracted. Niet elke overgang tussen deze statussen is toegestaan. Zo mag een gepubliceerd bericht alleen worden teruggetrokken, en kan een ingetrokken bericht niet opnieuw worden aangepast.
Union types en tuples
We willen de overgang van een oude naar een nieuwe status als tuple representeren. Een eerste poging is:
type MessageStatus = "draft" | "pending" | "published" | "retracted";
type Transition = [MessageStatus, MessageStatus];
Dit type is niet specifiek genoeg. Beide tuple-posities mogen elk een willekeurige status bevatten; ook combinaties die we niet willen toestaan zijn daarmee geldig. Union types verdelen zich niet automatisch over tuple types.
Met een distributieve helper maken we expliciet een union van tuples:
type CartesianProduct<A extends string, B extends string> = A extends any
? B extends any
? [A, B]
: never
: never;
type Transition = CartesianProduct<MessageStatus, MessageStatus>;
Daarna beperken we de geldige overgangen tot de regels van onze applicatie:
type AllowedTransitions =
| ["draft", "draft"]
| ["draft", "pending"]
| ["pending", "draft"]
| ["pending", "pending"]
| ["published", "retracted"];
Runtime-validatie als type-narrowing
Runtime-validatie blijft belangrijk. In dit voorbeeld verfijnen de controles het type tegelijk: na de checks weet TypeScript dat alleen de toegestane gevallen over zijn.
const transition: Transition = [oldStatus, newStatus];
if (transition[1] === "published") {
throw new Error("Cannot set status to published when updating a message.");
}
if (transition[0] === "retracted") {
throw new Error("Retracted messages cannot be updated.");
}
if (transition[0] === "published" && transition[1] !== "retracted") {
throw new Error("Published messages can only be retracted.");
}
if (transition[0] !== "published" && transition[1] === "retracted") {
throw new Error("Only published messages can be retracted.");
}
transition satisfies AllowedTransitions;
Het resultaat is code die correct is, omdat onmogelijke overgangen worden uitgesloten; onderhoudbaar is, omdat de regels expliciet en lokaal zichtbaar zijn; en statisch controleerbaar is, omdat een typefout verschijnt als een nieuwe overgang wordt toegevoegd zonder de controles aan te passen.
De volledige code staat in het TypeScript Playground.