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.
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:
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:
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:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueHet 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:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// Resultaat: stringHet 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:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Resultaat: number | booleanExclude — het tegenovergestelde, verwijdert overeenkomende leden:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Resultaat: number | booleanDistributive 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.
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:
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:
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: stringDit 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#
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript ziet het patroon (...args) => iets, matcht je functie ertegen, en wijst het return type toe aan R.
Parameter Types Extraheren#
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:
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:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Resultaat: numberArray Element Types Extraheren#
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberMeerdere infer in Eén Type#
Je kunt infer meerdere keren gebruiken om verschillende delen te extraheren:
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:
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#
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:
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:
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:
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:
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:
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:
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:
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#
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:
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:
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:
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"); // ErrorStrings Parsen met Template Literals#
Je kunt template literal types met infer gebruiken om strings te parsen:
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:
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:
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:
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: stringVolledige 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:
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 optioneelLet 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:
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:
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:
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:
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' ontbreektDe 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:
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 AppEventsIk 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:
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:
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:
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:
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 UserIdZowel 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.
// 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 --noEmitduurt merkbaar langer na het toevoegen van een type- Je ziet "Type instantiation is excessively deep" errors
// 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 elementenWanneer 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
// 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:
// 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 zienDe & {} 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:
// 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 checkenSamenvatting#
- Conditional types (
T extends U ? X : Y) zijn if/else voor types. Let op distributief gedrag met unions. inferis 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 metasvoor 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.