Na jaren TypeScript schrijven voor projecten als SURF, Remembo en diverse SaaS-platforms heb ik een handvol patronen gevonden die ik in vrijwel elk project gebruik. Geen academische theorie β gewoon patronen die bugs voorkomen en code leesbaarder maken.
Discriminated Unions
Modelleer elke mogelijke state expliciet β impossible states worden onmogelijk
Branded Types
Maak UserId en OrderId onverwisselbaar β puur compile-time, 0 bytes runtime
Builder Pattern
Complexe objecten stap voor stap opbouwen met method chaining
Result Type
Maak faalscenarios expliciet β de compiler dwingt error handling af
Const Assertions
Configuratie als single source of truth voor runtime waarden Γ©n types
1. Discriminated unions voor state management
Meest waardevolle patroon
Discriminated unions zijn het patroon met de hoogste return on investment. Ze kosten weinig moeite om te implementeren maar voorkomen een hele categorie bugs: impossible states.
Het meest waardevolle patroon dat TypeScript biedt. In plaats van optionele velden en null-checks, modelleer je elke mogelijke state expliciet:
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderState<T>(state: AsyncState<T>) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <Data value={state.data} />;
case "error":
return <ErrorMessage error={state.error} />;
}
}De compiler garandeert dat je data alleen kunt benaderen wanneer status === "success". Geen runtime errors, geen data?.maybe?.exists chains.
Ik gebruik dit voor alles: API responses, formuliervalidatie, wizard-stappen, websocket-connecties. Het maakt impossible states letterlijk onmogelijk.
2. Branded types voor domeinveiligheid
Een string is een string β maar een UserId is geen OrderId. Met branded types maak je dat onderscheid op type-niveau:
type Brand<T, B extends string> = T & { __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function createUserId(id: string): UserId {
return id as UserId;
}
function getOrder(orderId: OrderId) {
// ...
}
const userId = createUserId("usr_123");
// getOrder(userId); // β Type error! UserId is niet OrderIdDit kost nul bytes runtime β het is puur een compile-time check. Ik gebruik het in elk project waar IDs worden doorgegeven tussen functies. Bij het SURF platform voorkwam dit meerdere bugs waar een CollectionId per ongeluk als MaterialId werd gebruikt.
3. Builder pattern voor complexe configuratie
Wanneer een object veel optionele velden heeft, is een builder patroon leesbaarder dan een constructor met 15 parameters:
class QueryBuilder<T> {
private filters: Record<string, unknown> = {};
private sortField?: string;
private sortOrder: "asc" | "desc" = "asc";
private limitValue = 50;
where(field: string, value: unknown) {
this.filters[field] = value;
return this;
}
sort(field: string, order: "asc" | "desc" = "asc") {
this.sortField = field;
this.sortOrder = order;
return this;
}
limit(n: number) {
this.limitValue = n;
return this;
}
build(): Query {
return {
filters: this.filters,
sort: this.sortField
? { field: this.sortField, order: this.sortOrder }
: undefined,
limit: this.limitValue,
};
}
}
// Gebruik
const query = new QueryBuilder()
.where("status", "active")
.where("org_id", orgId)
.sort("created_at", "desc")
.limit(25)
.build();De method chaining maakt de intent direct duidelijk. Ik gebruik dit voor API queries, e-mail templates, en configuratie-objecten.
Builder pattern vuistregel
Gebruik het Builder pattern wanneer een object meer dan 5 optionele parameters heeft. Method chaining maakt de intent duidelijker dan een constructor met 15 argumenten.
4. Result type in plaats van try/catch
try/catch aanpak
- βErrors zijn onzichtbaar in het type systeem
- βCaller kan vergeten om errors af te handelen
- βExceptions doorbreken de normale flow
- βMoeilijk te composeren met andere operaties
- βGeen compile-time garanties
Result type aanpak
- β Errors zijn expliciet in het type systeem
- β Compiler dwingt error handling af
- β Normale control flow behouden
- β Eenvoudig te chainen en composeren
- β Volledige compile-time type safety
Exceptions zijn goto-statements in vermomming. Een Result type maakt faalscenarios expliciet in het type systeem:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// Gebruik
async function parseCSV(file: File): Result<Contact[], ParseError> {
try {
const text = await file.text();
const rows = parse(text);
return ok(rows.map(toContact));
} catch (e) {
return err(new ParseError(`Ongeldig CSV-formaat: ${e}`));
}
}
// Caller MOET het error-geval afhandelen
const result = await parseCSV(uploadedFile);
if (!result.ok) {
showError(result.error.message);
return;
}
// result.value is nu gegarandeerd Contact[]
const contacts = result.value;Het verschil met try/catch: de caller kan niet vergeten om het error-geval af te handelen. De compiler dwingt het af.
5. Const assertions voor configuratie
as const is een klein keyword met grote impact. Het vertelt TypeScript dat een waarde letterlijk is en niet breder geΓ―nfereerd mag worden:
const PLANS = {
starter: { price: 49, agents: 1, contacts: 500 },
professional: { price: 149, agents: 4, contacts: 10_000 },
enterprise: { price: null, agents: Infinity, contacts: Infinity },
} as const;
type PlanKey = keyof typeof PLANS; // "starter" | "professional" | "enterprise"
type Plan = (typeof PLANS)[PlanKey];
function getPlanPrice(plan: PlanKey): number | null {
return PLANS[plan].price;
}
// getPlanPrice("free"); // β Type error: "free" is geen PlanKeyZonder as const zou price als number worden geΓ―nfereerd in plaats van 49 | 149 | null. Met const assertions wordt je configuratie-object een single source of truth voor zowel runtime-waarden als types.
Wanneer wel, wanneer niet
Niet elk patroon is altijd de juiste keuze:
- Discriminated unions: Altijd. Geen nadelen, alleen voordelen.
- Branded types: Wanneer je meer dan twee ID-types hebt die door dezelfde functies gaan.
- Builder pattern: Wanneer een object meer dan 5 optionele configuratie-opties heeft.
- Result type: Voor operaties die voorspelbaar kunnen falen (parsing, validatie, externe API calls).
- Const assertions: Voor elke configuratie die zowel als runtime-waarde als type wordt gebruikt.
Het doel is altijd hetzelfde: laat de compiler het werk doen zodat je minder tests nodig hebt en minder bugs in productie krijgt.