Ga naar inhoud
·20 min leestijd

TypeScript Geavanceerde Types: Conditional, Mapped, en Template Literal Magie

Een diepe duik in de krachtigste type-features van TypeScript — conditional types, mapped types, het infer keyword en template literal types. Echte patronen die je daadwerkelijk zult gebruiken.

Delen:X / TwitterLinkedIn

De meeste TypeScript-developers bereiken een plateau op dezelfde plek. Ze kennen interfaces, generics en union types. Ze kunnen een React-component typen. Ze kunnen de rode kronkellijntjes laten verdwijnen. Maar ze behandelen het type-systeem als een noodzakelijk kwaad — iets dat bestaat om bugs te voorkomen, niet iets dat hen actief helpt betere software te ontwerpen.

Ik zat zelf zo'n twee jaar op dat plateau. Wat mij eruit trok was het besef dat TypeScript's type-systeem op zichzelf een programmeertaal is. Het heeft conditionals, loops, pattern matching, recursie en stringmanipulatie. Zodra je dat internaliseert, verandert alles. Je stopt met vechten tegen de compiler en begint ermee samen te werken.

Dit artikel behandelt de type-level features die ik constant gebruik in productiecode. Geen academische oefeningen — echte patronen die me voor echte bugs hebben behoed.

De Type-Level Programming Mindset#

Voordat we in de syntax duiken, wil ik herkaderen hoe je over types nadenkt.

In value-level programming schrijf je functies die data transformeren at runtime:

typescript
function extractIds(users: User[]): number[] {
  return users.map(u => u.id);
}

In type-level programming schrijf je types die andere types transformeren at compile time:

typescript
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];

Het mentale model is hetzelfde — input, transformatie, output. Het verschil is dat type-level code draait tijdens compilatie, niet at runtime. Het produceert nul JavaScript. Het enige doel is om onmogelijke toestanden onrepresenteerbaar te maken.

Als je ooit een functie hebt geschreven waarbij drie van de vier parameters alleen zinnig zijn in bepaalde combinaties, of waarbij het return type afhangt van wat je erin stopt, dan had je type-level programming al nodig. Je wist misschien alleen niet dat TypeScript het kon uitdrukken.

Conditional Types#

Conditional types zijn de if/else van het type-systeem. De syntax lijkt op een ternary:

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

Het extends keyword betekent hier geen overerving. Het betekent "is toewijsbaar aan." Denk eraan als "past T in de vorm van string?"

Je Eigen Utility Types Bouwen#

Laten we een aantal van TypeScript's ingebouwde utility types herbouwen om te begrijpen hoe ze werken.

NonNullable — verwijdert null en undefined uit een union:

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Resultaat: string

Het never type is de "lege verzameling" — iets verwijderen uit een union. Wanneer een branch van een conditional type resolvet naar never, verdwijnt dat lid van de union simpelweg.

Extract — behoudt alleen union-leden die aan een beperking voldoen:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Resultaat: number | boolean

Exclude — het tegenovergestelde, verwijdert overeenkomende leden:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Resultaat: number | boolean

Distributive Conditional Types#

Hier wordt het interessant, en hier raken de meeste mensen in de war. Wanneer je een union type doorgeeft aan een conditional type, distribueert TypeScript de conditie over elk lid van de union individueel.

typescript
type ToArray<T> = T extends unknown ? T[] : never;
 
type Result = ToArray<string | number>;
// Je zou verwachten: (string | number)[]
// Werkelijk resultaat: string[] | number[]

TypeScript evalueert niet (string | number) extends unknown. In plaats daarvan evalueert het string extends unknown en number extends unknown apart, en maakt er vervolgens een union van.

Daarom werken Extract en Exclude zoals ze werken. De distributie gebeurt automatisch wanneer T een "naakte" type parameter is (niet in iets gewrapt).

Als je distributie wilt voorkomen, wrap beide kanten in een tuple:

typescript
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
 
type Result = ToArrayNonDist<string | number>;
// Resultaat: (string | number)[]

Ik ben hier vaker door gebeten dan ik wil toegeven. Als je conditional type onverwachte resultaten geeft met union-inputs, is distributie vrijwel altijd de reden.

Praktisch Voorbeeld: API Response Handler#

Hier is een patroon dat ik gebruik bij het afhandelen van API-responses die ofwel slagen of falen:

typescript
type ApiResponse<T> =
  | { status: "success"; data: T; error: never }
  | { status: "error"; data: never; error: string };
 
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
 
type UserResponse = ApiResponse<{ id: number; name: string }>;
 
type UserData = ExtractData<UserResponse>;
// Resultaat: { id: number; name: string }
 
type UserError = ExtractError<UserResponse>;
// Resultaat: string

Dit is schoner dan voor elke response-vorm handmatige type guards schrijven.

Het infer Keyword#

infer is pattern matching voor types. Het laat je een type-variabele declareren binnen een conditional type die TypeScript voor je uitzoekt.

Denk eraan als: "Ik weet dat dit type een bepaalde vorm heeft. Trek het deel eruit dat me interesseert."

Return Types Extraheren#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScript ziet het patroon (...args) => iets, matcht je functie ertegen, en wijst het return type toe aan R.

Parameter Types Extraheren#

typescript
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
 
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]

Promises Uitpakken#

Deze gebruik ik de hele tijd bij het werken met async functies:

typescript
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
 
type A = UnpackPromise<Promise<string>>;  // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>;            // string (passthrough)

Voor diep geneste promises:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Resultaat: number

Array Element Types Extraheren#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

Meerdere infer in Eén Type#

Je kunt infer meerdere keren gebruiken om verschillende delen te extraheren:

typescript
type FirstAndLast<T extends unknown[]> =
  T extends [infer First, ...unknown[], infer Last]
    ? { first: First; last: Last }
    : never;
 
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Resultaat: { first: 1; last: 4 }

Praktijkvoorbeeld: Type-Safe Event Extractie#

Hier is een patroon dat ik gebruik in event-driven systemen:

typescript
type EventMap = {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "order:created": { orderId: string; total: number };
  "order:shipped": { orderId: string; trackingId: string };
};
 
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
 
// Extraheer alle user-gerelateerde events
type UserEvents = Extract<EventName, `user:${string}`>;
// Resultaat: "user:login" | "user:logout"
 
// Haal de payload voor alleen user events op
type UserEventPayloads = EventPayload<UserEvents>;
// Resultaat: { userId: string; timestamp: number } | { userId: string }

Mapped Types#

Mapped types laten je nieuwe object types maken door elke property van een bestaand type te transformeren. Ze zijn de map() van het type-systeem.

De Basis#

typescript
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
 
type Partial<T> = {
  [K in keyof T]?: T[K];
};

keyof T geeft je een union van alle property keys. [K in ...] itereert over elk ervan. T[K] is het type van die property.

Utility Types Helemaal Opnieuw Bouwen#

Required — maak alle properties verplicht:

typescript
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

De -? syntax verwijdert de optional modifier. Op vergelijkbare wijze verwijdert -readonly readonly.

Pick — selecteer specifieke properties:

typescript
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};
 
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}
 
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

Omit — verwijder specifieke properties:

typescript
type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
};
 
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }

Record — maak een object type met specifieke keys en value types:

typescript
type MyRecord<K extends string | number | symbol, V> = {
  [P in K]: V;
};
 
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }

Key Remapping met as#

TypeScript 4.1 introduceerde key remapping, waarmee je propertynamen kunt transformeren:

typescript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
interface Person {
  name: string;
  age: number;
  email: string;
}
 
type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

Je kunt ook properties filteren door te remappen naar never:

typescript
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }

Dit is ongelooflijk krachtig. Je itereert tegelijkertijd over properties, filtert ze en transformeert hun namen — allemaal op type-level.

Praktisch Voorbeeld: Formulier Validatie Types#

Ik heb dit patroon in meerdere formulierbibliotheken gebruikt:

typescript
interface FormFields {
  username: string;
  email: string;
  age: number;
  bio: string;
}
 
type ValidationErrors<T> = {
  [K in keyof T]?: string[];
};
 
type TouchedFields<T> = {
  [K in keyof T]?: boolean;
};
 
type DirtyFields<T> = {
  [K in keyof T]?: boolean;
};
 
type FormState<T> = {
  values: T;
  errors: ValidationErrors<T>;
  touched: TouchedFields<T>;
  dirty: DirtyFields<T>;
  isValid: boolean;
  isSubmitting: boolean;
};
 
// Nu is je formulierstaat volledig getypeerd op basis van de velden
type MyFormState = FormState<FormFields>;

Voeg een veld toe aan FormFields, en elk gerelateerd type wordt automatisch bijgewerkt. Verwijder een veld, en de compiler vangt elke referentie. Dit is het soort ding dat hele categorieen bugs voorkomt.

Template Literal Types#

Template literal types laten je strings manipuleren op type-level. Ze gebruiken dezelfde backtick-syntax als JavaScript template literals, maar dan voor types.

Basis String Manipulatie#

typescript
type Greeting = `Hello, ${string}`;
 
const a: Greeting = "Hello, world";    // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world";    // Error!

TypeScript biedt ook ingebouwde string-manipulatie types:

typescript
type Upper = Uppercase<"hello">;     // "HELLO"
type Lower = Lowercase<"HELLO">;     // "hello"
type Cap = Capitalize<"hello">;      // "Hello"
type Uncap = Uncapitalize<"Hello">;  // "hello"

Event Name Patronen#

Dit is waar template literal types echt uitblinken. Hier is een patroon dat ik gebruik voor type-safe event systemen:

typescript
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
 
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"

Negen events uit twee type parameters. En ze zijn allemaal type-safe.

CSS-in-TypeScript Patronen#

Template literal types kunnen CSS-patronen afdwingen op type-level:

typescript
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
 
function setWidth(value: CSSValue): void {
  // implementatie
}
 
setWidth("100px");   // OK
setWidth("2.5rem");  // OK
setWidth("50%");     // OK
// setWidth("100");  // Error: geen geldige CSS waarde
// setWidth("big");  // Error

Strings Parsen met Template Literals#

Je kunt template literal types met infer gebruiken om strings te parsen:

typescript
type ParseRoute<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ParseRoute<`/${Rest}`>
    : T extends `${infer _Start}:${infer Param}`
      ? Param
      : never;
 
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Resultaat: "userId" | "postId"

Dit is de basis van hoe type-safe routing libraries zoals tRPC werken. De string /users/:userId/posts/:postId wordt op type-level geparsed om parameternamen te extraheren.

Template Literals Combineren met Mapped Types#

Dit is waar het echt krachtig wordt:

typescript
type EventHandlers<T extends Record<string, unknown>> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (
    newValue: T[K],
    oldValue: T[K]
  ) => void;
};
 
interface ThemeConfig {
  color: string;
  fontSize: number;
  darkMode: boolean;
}
 
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
//   onColorChange: (newValue: string, oldValue: string) => void;
//   onFontSizeChange: (newValue: number, oldValue: number) => void;
//   onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }

Van een simpele config interface krijg je volledig getypeerde event handlers met correcte parameter types. Dit is het soort type-level code dat daadwerkelijk ontwikkeltijd bespaart.

Geavanceerd: Dot-Notation Pad Types#

Hier is een geavanceerder patroon — het genereren van alle mogelijke dot-notatie paden door een genest object:

typescript
type DotPaths<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;
 
interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  server: {
    port: number;
  };
}
 
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"

Nu kun je een getConfig functie schrijven die elk mogelijk pad autocomplete en het juiste type voor elk pad retourneert:

typescript
type GetValueByPath<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? GetValueByPath<T[Key], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;
 
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
  // implementatie
  const keys = path.split(".");
  let result: unknown = config;
  for (const key of keys) {
    result = (result as Record<string, unknown>)[key];
  }
  return result as GetValueByPath<Config, P>;
}
 
const host = getConfig("database.host");         // type: string
const port = getConfig("database.port");         // type: number
const username = getConfig("database.credentials.username"); // type: string

Volledige autocomplete, volledige type safety, nul runtime overhead van de types.

Recursieve Types#

TypeScript ondersteunt recursieve type-definities, waarmee je willekeurig geneste structuren kunt afhandelen.

Deep Partial#

De ingebouwde Partial<T> maakt alleen top-level properties optioneel. Voor geneste objecten heb je recursie nodig:

typescript
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? T[K] extends Array<infer U>
      ? Array<DeepPartial<U>>
      : DeepPartial<T[K]>
    : T[K];
};
 
interface Settings {
  theme: {
    colors: {
      primary: string;
      secondary: string;
    };
    fonts: {
      heading: string;
      body: string;
    };
  };
  notifications: {
    email: boolean;
    push: boolean;
  };
}
 
type PartialSettings = DeepPartial<Settings>;
// Elke geneste property is nu optioneel

Let op de array-check: zonder die check zouden arrays als objecten behandeld worden en hun numerieke indices optioneel worden, wat niet is wat je wilt.

Deep Readonly#

Hetzelfde patroon, andere modifier:

typescript
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : T[K] extends Array<infer U>
        ? ReadonlyArray<DeepReadonly<U>>
        : DeepReadonly<T[K]>
    : T[K];
};

De Function check is belangrijk — zonder die check zou readonly worden toegepast op de interne structuur van functies, wat geen zin heeft.

JSON Type#

Hier is een klassiek recursief type — het representeren van elke geldige JSON-waarde:

typescript
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
 
function parseJson(input: string): JsonValue {
  return JSON.parse(input);
}
 
function isJsonObject(value: JsonValue): value is JsonObject {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

Dit is veel beter dan any gebruiken voor JSON-parsing. Het vertelt de consument "je krijgt iets terug, maar je moet het narrowen voordat je het gebruikt." Wat precies de waarheid is.

Recursief Type voor Geneste Menu's#

Een praktisch voorbeeld — het typen van een navigatiemenu-structuur:

typescript
interface MenuItem {
  label: string;
  href: string;
  icon?: string;
  children?: MenuItem[];
}
 
type Menu = MenuItem[];
 
const navigation: Menu = [
  {
    label: "Products",
    href: "/products",
    children: [
      {
        label: "Software",
        href: "/products/software",
        children: [
          { label: "IDE", href: "/products/software/ide" },
          { label: "CLI Tools", href: "/products/software/cli" },
        ],
      },
      { label: "Hardware", href: "/products/hardware" },
    ],
  },
  { label: "About", href: "/about" },
];

De recursieve children property geeft je oneindige nesting met volledige type safety.

Praktijkpatronen#

Laat me een paar patronen delen die ik daadwerkelijk in productiesystemen heb gebruikt.

Builder Pattern met Types#

Het builder pattern wordt aanzienlijk nuttiger wanneer het type-systeem bijhoudt wat er ingesteld is:

typescript
type RequiredKeys = "host" | "port" | "database";
 
type BuilderState = {
  [K in RequiredKeys]?: true;
};
 
class DatabaseConfigBuilder<State extends BuilderState = {}> {
  private config: Partial<{
    host: string;
    port: number;
    database: string;
    ssl: boolean;
    poolSize: number;
  }> = {};
 
  setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
    this.config.host = host;
    return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
  }
 
  setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
    this.config.port = port;
    return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
  }
 
  setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
    this.config.database = db;
    return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
  }
 
  setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
    this.config.ssl = ssl;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  setPoolSize(size: number): DatabaseConfigBuilder<State> {
    this.config.poolSize = size;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  build(
    this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
  ): Required<Pick<typeof this.config, "host" | "port" | "database">> &
    Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
    return this.config as ReturnType<typeof this.build>;
  }
}
 
// Dit compileert:
const config = new DatabaseConfigBuilder()
  .setHost("localhost")
  .setPort(5432)
  .setDatabase("myapp")
  .setSsl(true)
  .build();
 
// Dit faalt at compile time — .setDatabase() ontbreekt:
// const bad = new DatabaseConfigBuilder()
//   .setHost("localhost")
//   .setPort(5432)
//   .build(); // Error: 'database' ontbreekt

De build() methode wordt pas aanroepbaar wanneer alle verplichte velden zijn ingesteld. Dit wordt at compile time gevangen, niet at runtime.

Type-Safe Event Emitter#

Hier is een event emitter waarbij de payload types worden afgedwongen:

typescript
type EventMap = Record<string, unknown>;
 
class TypedEventEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};
 
  on<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }
 
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach((listener) => listener(payload));
  }
 
  off<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    const handlers = this.listeners[event];
    if (handlers) {
      this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
    }
  }
}
 
// Gebruik
interface AppEvents {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "notification:new": { message: string; level: "info" | "warning" | "error" };
}
 
const emitter = new TypedEventEmitter<AppEvents>();
 
// Volledig getypeerd — IDE autocomplete event-namen en payload-vormen
emitter.on("user:login", (payload) => {
  console.log(payload.userId);     // string
  console.log(payload.timestamp);  // number
});
 
// Type error: payload komt niet overeen
// emitter.emit("user:login", { userId: "123" });
// Error: 'timestamp' ontbreekt
 
// Type error: event bestaat niet
// emitter.on("user:signup", () => {});
// Error: "user:signup" zit niet in AppEvents

Ik heb dit patroon in drie verschillende productieprojecten gebruikt. Het vangt hele categorieen event-gerelateerde bugs at compile time.

Discriminated Unions met Exhaustive Checking#

Discriminated unions zijn waarschijnlijk het meest nuttige enkele patroon in TypeScript. Gecombineerd met exhaustive checking garanderen ze dat je elk geval afhandelt:

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      // Deze regel zorgt voor exhaustive checking
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Als iemand een nieuwe shape-variant toevoegt (zeg "pentagon"), zal deze functie niet meer compileren omdat never geen waarde kan krijgen. De compiler dwingt je om elk geval af te handelen.

Ik ga hierin verder met een helperfunctie:

typescript
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
  }
}

Type-Safe State Machines#

Discriminated unions modelleren ook state machines prachtig:

typescript
type ConnectionState =
  | { status: "disconnected" }
  | { status: "connecting"; attempt: number }
  | { status: "connected"; socket: WebSocket; connectedAt: Date }
  | { status: "error"; error: Error; lastAttempt: Date };
 
type ConnectionAction =
  | { type: "CONNECT" }
  | { type: "CONNECTED"; socket: WebSocket }
  | { type: "DISCONNECT" }
  | { type: "ERROR"; error: Error }
  | { type: "RETRY" };
 
function connectionReducer(
  state: ConnectionState,
  action: ConnectionAction
): ConnectionState {
  switch (state.status) {
    case "disconnected":
      if (action.type === "CONNECT") {
        return { status: "connecting", attempt: 1 };
      }
      return state;
 
    case "connecting":
      if (action.type === "CONNECTED") {
        return {
          status: "connected",
          socket: action.socket,
          connectedAt: new Date(),
        };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "connected":
      if (action.type === "DISCONNECT") {
        state.socket.close();
        return { status: "disconnected" };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "error":
      if (action.type === "RETRY") {
        return { status: "connecting", attempt: 1 };
      }
      if (action.type === "DISCONNECT") {
        return { status: "disconnected" };
      }
      return state;
 
    default:
      assertNever(state, "Unknown connection state");
  }
}

Elke state heeft alleen de properties die zinvol zijn voor die state. Je kunt niet bij socket wanneer je disconnected bent. Je kunt niet bij error wanneer je connected bent. Het type-systeem dwingt de beperkingen van de state machine af.

Branded Types voor Domeinveiligheid#

Nog een patroon dat ik essentieel vind — branded types gebruiken om te voorkomen dat je waarden verwisselt die toevallig hetzelfde onderliggende type delen:

typescript
type Brand<T, B extends string> = T & { readonly __brand: B };
 
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
 
function createUserId(id: string): UserId {
  return id as UserId;
}
 
function createOrderId(id: string): OrderId {
  return id as OrderId;
}
 
function getUser(id: UserId): Promise<User> {
  // ...
}
 
function getOrder(id: OrderId): Promise<Order> {
  // ...
}
 
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
 
getUser(userId);   // OK
// getUser(orderId); // Error! OrderId is niet toewijsbaar aan UserId

Zowel UserId als OrderId zijn strings at runtime. Maar at compile time zijn ze aparte types. Je kunt letterlijk geen order-ID doorgeven waar een user-ID wordt verwacht. Dit heeft echte bugs gevangen in elk project waar ik het heb gebruikt.

Veelvoorkomende Valkuilen#

Geavanceerde types zijn krachtig, maar ze komen met vallen. Dit is wat ik op de harde manier heb geleerd.

Circulaire Referentie Limieten#

TypeScript heeft een recursiedieptelimiet voor types (momenteel rond de 50 niveaus, hoewel het varieert). Als je te diep gaat, krijg je de gevreesde "Type instantiation is excessively deep and possibly infinite" error.

typescript
// Dit zal de recursielimiet raken voor diep geneste objecten
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
 
// Fix: Voeg een diepteteller toe
type DeepPartialSafe<T, Depth extends number[] = []> =
  Depth["length"] extends 10
    ? T
    : {
        [K in keyof T]?: T[K] extends object
          ? DeepPartialSafe<T[K], [...Depth, 0]>
          : T[K];
      };

De diepteteller-truc gebruikt een tuple die groeit bij elke recursieve stap. Wanneer het je limiet bereikt, stopt de recursie.

Prestatie-implicaties#

Complexe types kunnen je editor en buildtijden aanzienlijk vertragen. Ik heb projecten gezien waar een enkel te slim type 3 seconden toevoegde aan de feedbackloop van elke toetsaanslag.

Waarschuwingssignalen:

  • Je IDE doet er meer dan 2 seconden over om autocomplete te tonen
  • tsc --noEmit duurt merkbaar langer na het toevoegen van een type
  • Je ziet "Type instantiation is excessively deep" errors
typescript
// Dit is te slim — het genereert een combinatorische explosie
type AllCombinations<T extends string[]> = T extends [
  infer First extends string,
  ...infer Rest extends string[]
]
  ? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
  : "";
 
// Doe dit niet met meer dan 5-6 elementen

Wanneer je Geavanceerde Types NIET Moet Gebruiken#

Dit is misschien de belangrijkste sectie. Geavanceerde types moeten worden gebruikt wanneer ze bugs voorkomen of de developer experience verbeteren. Ze moeten niet worden gebruikt om te showen.

Gebruik ze niet wanneer:

  • Een simpele Record<string, unknown> zou volstaan
  • Het type maar op één plek wordt gebruikt en een concreet type duidelijker zou zijn
  • Je meer tijd besteedt aan het debuggen van het type dan het zou besparen
  • Je team het niet kan lezen of onderhouden
  • Een runtime check geschikter zou zijn

Gebruik ze wel wanneer:

  • Je een patroon hebt dat zich herhaalt over veel types (mapped types)
  • Het return type van een functie afhangt van de input (conditional types)
  • Je een library-API bouwt die zelfdocumenterend moet zijn
  • Je illegale toestanden onrepresenteerbaar wilt maken
  • Je het zat bent dat dezelfde categorie bug steeds opduikt in code reviews
typescript
// Over-engineered — doe dit niet voor een simpele config
type Config = DeepReadonly<
  DeepRequired<
    Merge<DefaultConfig, Partial<UserConfig>>
  >
>;
 
// Doe gewoon dit
interface Config {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

Mijn vuistregel: als het uitleggen van het type langer duurt dan het uitleggen van de bug die het voorkomt, vereenvoudig het.

Complexe Types Debuggen#

Wanneer een complex type niet werkt, gebruik ik deze helper om te "zien" wat TypeScript heeft opgelost:

typescript
// Breidt een type uit voor inspectie in IDE hover tooltips
type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};
 
// Gebruik het om te debuggen
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Hover nu over Debug in je IDE om het opgeloste type te zien

De & {} truc dwingt TypeScript om het type eagerly te evalueren in plaats van je de type-alias te tonen. Dit heeft me uren verwarring bespaard.

Nog een techniek — isoleer en test incrementeel:

typescript
// In plaats van dit allemaal tegelijk te debuggen:
type Final = StepThree<StepTwo<StepOne<Input>>>;
 
// Breek het op:
type AfterStep1 = StepOne<Input>;       // hover om te checken
type AfterStep2 = StepTwo<AfterStep1>;  // hover om te checken
type AfterStep3 = StepThree<AfterStep2>; // hover om te checken

Samenvatting#

  • Conditional types (T extends U ? X : Y) zijn if/else voor types. Let op distributief gedrag met unions.
  • infer is pattern matching — gebruik het om types te extraheren uit structuren zoals functiesignaturen, promises en arrays.
  • Mapped types ({ [K in keyof T]: ... }) itereren over properties. Combineer met as voor key remapping en filtering.
  • Template literal types manipuleren strings op type-level. Gecombineerd met mapped types zijn ze ongelooflijk krachtig voor API-ontwerp.
  • Recursieve types handelen geneste structuren af, maar hebben dieptelimieten nodig om compiler-explosies te voorkomen.
  • Discriminated unions met exhaustive checking zijn het meest waardevolle enkele patroon in TypeScript. Gebruik ze overal.
  • Branded types voorkomen het verwisselen van waarden die hetzelfde onderliggende type delen. Simpel te implementeren, grote impact.
  • Over-engineer types niet. Als het type moeilijker te begrijpen is dan de bug die het voorkomt, vereenvoudig het. Het doel is je codebase veiliger maken, niet een type-golf wedstrijd winnen.

Het TypeScript type-systeem is Turing-compleet, wat betekent dat je er bijna alles mee kunt doen. De kunst is weten wanneer je dat moet doen.

Gerelateerde artikelen