Ga naar de inhoud

Van statuslogica naar types

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 erg mooi uit de verf komt. Wat ik nu met jullie wil delen is zo’n geval waarin we met slimme types de correctheid van statustransities met het typesysteem van TypeScript kunnen controleren.

Context

Voor een nieuw project is het nodig om berichten te kunnen plaatsen. Deze berichten worden na 30 seconden automatisch gepubliceerd. Echter is het ook mogelijk om een concept te maken (dat nog niet gepubliceerd wordt) of een reeds gepubliceerd bericht terug te trekken.

Deze state transitions zijn hieronder weergegeven:

Het is mogelijk de status van het bericht aan te passen. Echter zijn slechts enkele transities toegestaan.

Probleem: berichten mogen alleen legitiem van status veranderen

We willen natuurlijk de input valideren om te zorgen dat reeds gepubliceerde berichten niet zomaar terug springen naar concept. Verder willen we goede foutmeldingen kunnen geven. Hier hebben we dus een aantal input validaties voor nodig.

Om te beginnen definiëren we het type van een status:

TypeScript
type MessageStatus = "draft" | "pending" | "published" | "retracted";

Het aanpassen van de status van een bericht ziet er ongeveer als volgt uit:

TypeScript
export async function updateMessage(
  messageId: string,
	newStatus: MessageStatus
) {
	const oldStatus = await getOldStatus(messageId);

  // Validate valid status transition

	return ...
}

Tot zover niets bijzonders. Maar hier gaan we wat TypeScript-magie toepassen.

Distributiviteit en tuple types

We willen de transitie van oldStatus naar newStatus representeren als een tuple. Een eerste poging zou kunnen zijn:

TypeScript
type Transition = [MessageStatus, MessageStatus]; // = ["draft" | "pending" | "published" | "retracted", "draft" | "pending" | "published" | "retracted"]

Maar dit type is niet specifiek genoeg. [MessageStatus, MessageStatus] beschrijft één tuple type waarin beide posities elk een willekeurige waarde uit de union MessageStatus mogen bevatten. TypeScript beschouwt dit als een tuple met twee losse union types, niet als de verzameling van alle mogelijke combinaties tussen de twee.

Wiskundig gezegd: de distributiviteit van union types over tuple types geldt niet automatisch in TypeScript. [A | B, C | D] betekent niet hetzelfde als alle combinaties van [A, C] | [A, D] | [B, C] | [B, D].

We hebben dus iets beters nodig om de transities goed te representeren!

Oplossing: union of tuples

Dat doen we door het cartesisch product van MessageStatus × MessageStatus expliciet te definiëren met een type-helper:

TypeScript
type CartesianProduct<A extends string, B extends string> = A extends any
	? B extends any
		? [A, B]
		: never
	: never;

We kunnen hiermee wél alle combinaties als union van tuples krijgen:

TypeScript
type Transition = CartesianProduct<MessageStatus, MessageStatus>

// = | ["draft", "draft"]
//   | ["draft", "pending"]
//   | ["draft", "published"]
//   | ["draft", "retracted"]
//   | ["pending", "draft"]
//   | ...  etc

Als we dit samenvoegen krijgen we de mogelijkheid om via type-narrowing bepaalde transities weg te filteren.

Allowed transitions

Daarna kunnen we dit type nog verder beperken tot alleen de toegestane overgangen:

TypeScript
type AllowedTransitions =
	| ["draft", "draft"]
	| ["draft", "pending"]
	| ["pending", "draft"]
	| ["pending", "pending"]
	| ["published", "retracted"];

Op die manier kunnen we statisch, op type-niveau, controleren of een transitie toegestaan is:

TypeScript
const transition: Transition = [oldStatus, newStatus];

// We get a type-error if faulty transitions are still possible
transition satisfies AllowedTransitions;

Runtime validatie als type-narrowing

Hoewel we met types een sterke basis leggen, blijft runtime-validatie belangrijk. Maar wat hier echt interessant is: deze validaties doen meer dan alleen fouten voorkomen — ze helpen TypeScript om types automatisch te verfijnen.

TypeScript
	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. They should be deleted using the DELETE         endpoint.",
		);
	}

Door deze voorwaarden eerst uit te sluiten, weet TypeScript daarna dat alleen de resterende, geldige gevallen overblijven. Het type van transition is daarmee verengd tot een subset van Transition, namelijk precies de AllowedTransitions.

Het resultaat is code die:

  • correct is (onmogelijke overgangen worden uitgesloten),
  • onderhoudbaar is (alle regels zijn expliciet en lokaal zichtbaar),
  • en statisch controleerbaar is (je krijgt een typefout als een nieuwe transitie wordt toegevoegd zonder de checks aan te passen).

De hele code is hier te vinden: TypeScript Playground

TypeScript
type MessageStatus = "draft" | "pending" | "published" | "retracted";

type CartesianProduct<A extends string, B extends string> = A extends any
	? B extends any
		? [A, B]
		: never
	: never;

type Transition = CartesianProduct<MessageStatus, MessageStatus>

type AllowedTransitions =
	| ["draft", "draft"]
	| ["draft", "pending"]
	| ["pending", "draft"]
	| ["pending", "pending"]
	| ["published", "retracted"];

function getOldStatus(id: string): MessageStatus {
    return "draft";
}

export async function updateMessage(
  messageId: string,
	newStatus: MessageStatus
) {
	const oldStatus = await getOldStatus(messageId);

    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. They should be deleted using the DELETE         endpoint.",
	 	);
  }

    // We get a type-error if faulty transitions are still possible
    transition satisfies AllowedTransitions;

	return ...;
}