Ga naar inhoud
Β·3 min

TypeScript design patterns die ik dagelijks gebruik

Vijf TypeScript patronen die mijn code leesbaarder en veiliger maken: discriminated unions, branded types, builder pattern, Result type en const assertions.

TypeScriptDesign PatternsClean Code

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.

Vijf TypeScript patronen die ik in elk project gebruik
0 patronen
Die ik dagelijks gebruik
0 bytes
Runtime overhead
0%
Minder runtime bugs
0%
Compile-time type safety
TypeScript patronen: maximale veiligheid zonder runtime kosten
🎯

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

De vijf patronen in volgorde van impact β€” begin bovenaan

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 OrderId

Dit 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
VS

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
Result types maken foutafhandeling expliciet en afdwingbaar

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 PlanKey

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

Blijf op de hoogte

Ontvang nieuwe artikelen direct in je inbox. Geen spam, alleen waardevolle content.

Plan een gesprek