TypeScript heeft een rijk typesysteem, maar je hoeft niet alles te begrijpen om productief te zijn. De realiteit is dat 90% van het werk wordt gedaan door een handvol functies die je elke dag gebruikt. Dit is de praktische referentie — primitieven, unions, generics, hulptypes en de narrowing-patronen die het allemaal doen klikken. Geen abstracte theorie; elk voorbeeld is het soort code dat je in een echt project schrijft. Als je JavaScript goed kent en wat TypeScript hebt gedaan maar je mentale model wilt versterken, dan is dit voor jou. Het volledige verhaal staat in het TypeScript Handboek — dit artikel is de 80/20-versie.
Primitieven en Letterwaarden
TypeScript's primitieve typen corresponderen direct met JavaScript's runtime-typen:
string, number, boolean, null,
undefined, bigint en symbol. De eerste
drie gebruik je constant; de rest komen voor in specifieke contexten.
// Primitives — the foundation
let userId: number = 42;
let username: string = 'alice';
let isActive: boolean = true;
let deletedAt: Date | null = null; // nullable pattern
let refreshToken: string | undefined; // optional patternWaar TypeScript interessant wordt zijn letterlijke typen. In plaats van alleen
te zeggen dat een waarde een string is, kun je zeggen dat het een van een specifieke set strings moet zijn.
Dit is veel nuttiger dan een gewone string-annotatie omdat TypeScript
ongeldige waarden tijdens het compileren kan opvangen:
type Direction = 'north' | 'south' | 'east' | 'west';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type StatusCode = 200 | 201 | 400 | 401 | 403 | 404 | 500;
function navigate(dir: Direction) {
console.log(`Moving ${dir}`);
}
navigate('north'); // ✅
navigate('up'); // ❌ Argument of type '"up"' is not assignable to type 'Direction'
// Combine with string for "known values plus free-form"
type EventName = 'click' | 'focus' | 'blur' | (string & {});(string & {})-truc aan het einde houdt automatisch aanvullen werkend voor de
bekende waarden terwijl het nog steeds elke string accepteert. Het is een veelvoorkomend patroon in design system-bibliotheken.interface vs type — Wanneer Welke Gebruiken
Dit is de vraag die elke TypeScript-beginner stelt. Het praktische antwoord: gebruik
interface voor objectvormen — vooral die welke openbare API-contracten vertegenwoordigen of
die andere typen zullen uitbreiden. Gebruik type voor unions, intersecties, gemaakte typen,
en alles dat niet puur een objectvorm is. In de praktijk zijn ze grotendeels uitwisselbaar voor
objectvormen — kies één conventie en wees consistent binnen een codebase.
// interface — object shapes, extensible via extends
interface User {
id: number;
email: string;
createdAt: Date;
}
interface AdminUser extends User {
role: 'admin';
permissions: string[];
}
// type — unions, intersections, aliases for anything
type ID = string | number;
type Nullable<T> = T | null;
// Intersection — combine two shapes (common for mixins / HOC props)
type WithTimestamps = {
createdAt: Date;
updatedAt: Date;
};
type UserRecord = User & WithTimestamps;
// type is required here — interface can't express a union of shapes
type ApiResponse<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: string; code: number };Eén zinvol verschil: interfaces ondersteunen declaratiefusie —
je kunt dezelfde interface twee keer declareren en TypeScript samenvoegt de definities. Dit is hoe
bibliotheken globale typen uitbreiden (bijv. eigenschappen toevoegen aan Window). Typen
fuseren niet; een type opnieuw declareren is een fout.
Union- en Intersectietypen
Uniontypen (A | B) zeggen "deze waarde is A of B". Intersectietypen
(A & B) zeggen "deze waarde is zowel A als B tegelijkertijd". Unions zijn
overal in echte code — het krachtigste patroon dat ze mogelijk maken is de
gediscrimineerde union, wat is hoe je data modelleert die in verschillende
toestanden kan zijn zonder overal optionele velden te gebruiken.
// Discriminated union — model API response states cleanly
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string; retryable: boolean };
// TypeScript narrows the type inside each branch
function render<T>(state: FetchState<T>) {
switch (state.status) {
case 'idle': return '<p>Not started</p>';
case 'loading': return '<p>Loading...</p>';
case 'success': return `<pre>${JSON.stringify(state.data)}</pre>`;
case 'error': return `<p>Error: ${state.error}</p>`;
}
}
// Intersection — common in React/Angular for composing prop types
type ButtonBaseProps = {
label: string;
disabled?: boolean;
};
type IconButtonProps = ButtonBaseProps & {
icon: string;
iconPosition: 'left' | 'right';
};Het gediscrimineerde union-patroon vertrouwt op een discriminant — een letterlijke
eigenschap (hier status) die uniek is voor elke variant. TypeScript gebruikt die
eigenschap om het volledige type te verfijnen binnen conditionele takken, waardoor je type-veilige toegang
geeft tot de variant-specifieke velden zoals data of error.
Generics — Het Deel Dat Iedereen Struikelt
Het motiverende probleem: je wilt een functie die werkt op meerdere typen, maar any
gebruiken gooit alle type-informatie weg. Generics lossen dit op — het zijn type-parameters,
geschreven tussen punthaken, waarmee een functie of interface over een familie van typen
kan werken met behoud van volledige typeveiligheid.
// Without generics — any loses all information
function first(arr: any[]): any {
return arr[0];
}
const x = first([1, 2, 3]); // x is 'any' — TypeScript can't help you here
// With generics — T is inferred from the argument
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // n: number | undefined ✅
const s = first(['a', 'b', 'c']); // s: string | undefined ✅De <T>-syntaxis declareert een type-parameter. Je kunt het van alles noemen
— T is gewoon de conventie voor één generiek type. Wanneer je de functie aanroept,
leidt TypeScript T af uit de argumenten, zodat je het zelden expliciet hoeft te schrijven.
Generieke interfaces zijn net zo nuttig voor het modelleren van API-vormen:
// Generic interface — the API wrapper pattern every codebase has
interface ApiResponse<T> {
data: T;
meta: {
page: number;
totalPages: number;
totalItems: number;
};
}
interface User {
id: number;
name: string;
email: string;
}
// The response type is fully typed — no casting needed
async function fetchUsers(): Promise<ApiResponse<User[]>> {
const res = await fetch('/api/users');
return res.json();
}
const response = await fetchUsers();
response.data[0].email; // ✅ TypeScript knows this is a string
response.meta.totalPages; // ✅ TypeScript knows this is a number
// Constrained generics — T must have an 'id' field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
const user = findById(users, 42); // T inferred as Useras om het
retourtype te krijgen dat je wilt. Dat zijn de twee signalen dat een generic schoner zou zijn.Hulptypes Die Je Echt Gebruikt
TypeScript wordt geleverd met een set ingebouwde hulptypes die bestaande typen transformeren naar nieuwe. Ze elimineren de noodzaak om type-definities handmatig te dupliceren of aan te passen. Hier zijn de typen die constant voorkomen in echte codebases:
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
createdAt: Date;
}
// Partial<T> — all properties become optional
// Perfect for PATCH/update payloads
type UpdateUserPayload = Partial<User>;
// { id?: number; name?: string; email?: string; ... }
async function updateUser(id: number, payload: Partial<User>) {
return fetch(`/api/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload)
});
}
// Required<T> — all properties become required (opposite of Partial)
interface Config {
apiUrl?: string;
timeout?: number;
retries?: number;
}
type ResolvedConfig = Required<Config>;
// { apiUrl: string; timeout: number; retries: number }
// Pick<T, K> — keep only specified properties
type UserSummary = Pick<User, 'id' | 'name' | 'email'>;
// Use in list views where you only need the display fields
// Omit<T, K> — exclude specified properties
type PublicUser = Omit<User, 'role' | 'createdAt'>;
// Safe to expose in API responses
// Readonly<T> — prevents mutation (great for config and frozen state)
const config: Readonly<ResolvedConfig> = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
};
// config.timeout = 10000; // ❌ Cannot assign to 'timeout' because it is a read-only property
// Record<K, V> — typed dictionary
type UserCache = Record<number, User>;
const cache: UserCache = {};
cache[42] = { id: 42, name: 'Alice', email: '[email protected]', role: 'user', createdAt: new Date() };
// Common pattern: mapping string keys to a known value shape
type FeatureFlags = Record<string, { enabled: boolean; rolloutPct: number }>;
// ReturnType<T> — infer the return type of a function
function createSession(userId: number) {
return {
token: crypto.randomUUID(),
userId,
expiresAt: new Date(Date.now() + 86_400_000)
};
}
type Session = ReturnType<typeof createSession>;
// { token: string; userId: number; expiresAt: Date }
// — no need to define the type separately and keep it in syncunknown vs any vs never
Deze drie typen verwarren de meeste ontwikkelaars totdat ze begrijpen welk probleem elk oplost.
any is de nooduitgang — het schakelt type-controle volledig uit voor die
waarde. Het is nuttig bij het migreren van JavaScript naar TypeScript, maar overmatig gebruik ondermijnt het doel van
TypeScript. unknown is het type-veilige alternatief: de waarde kan van alles zijn,
maar je moet het verfijnen voordat je er iets mee kunt doen.
never is een type dat nooit kan voorkomen — de bodem van de typehiërarchie.
// any — type checking disabled, use sparingly
function dangerousTransform(input: any) {
return input.toUpperCase(); // TypeScript won't warn even if this crashes at runtime
}
// unknown — safe alternative, forces you to check before using
function safeTransform(input: unknown): string {
if (typeof input === 'string') {
return input.toUpperCase(); // ✅ narrowed to string inside this block
}
if (typeof input === 'number') {
return input.toFixed(2); // ✅ narrowed to number
}
throw new Error(`Cannot transform value of type ${typeof input}`);
}
// Common use: parsing untrusted data (API responses, localStorage, user input)
function parseConfig(raw: unknown): ResolvedConfig {
if (
typeof raw === 'object' &&
raw !== null &&
'apiUrl' in raw &&
typeof (raw as any).apiUrl === 'string'
) {
return raw as ResolvedConfig;
}
throw new Error('Invalid config format');
}
// never — the exhaustive switch pattern
type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'square': return shape.side ** 2;
default:
// If you add a new Shape variant and forget to handle it here,
// TypeScript will error: Type 'NewShape' is not assignable to type 'never'
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}Type Narrowing in de Praktijk
Type narrowing is hoe TypeScript een breed type (string | number,
unknown, een gediscrimineerde union) verfijnt naar een specifiek type binnen een conditioneel blok.
De
TypeScript narrowing-documentatie
dekt elke guard diepgaand — hier zijn de patronen die je dagelijks schrijft.
// typeof — primitive narrowing
function formatValue(val: string | number | boolean): string {
if (typeof val === 'string') return val.trim();
if (typeof val === 'number') return val.toLocaleString();
return val ? 'Yes' : 'No';
// TypeScript knows val must be boolean here
}
// instanceof — class/object narrowing
function handleError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return 'An unknown error occurred';
}
// in operator — property existence check
type Cat = { meow(): void };
type Dog = { bark(): void };
function makeNoise(animal: Cat | Dog) {
if ('meow' in animal) {
animal.meow(); // TypeScript narrows to Cat
} else {
animal.bark(); // TypeScript narrows to Dog
}
}
// Discriminated union narrowing (most common in component state)
type LoadState<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; message: string };
function renderState<T>(state: LoadState<T>) {
if (state.status === 'success') {
console.log(state.data); // ✅ data is accessible
} else if (state.status === 'error') {
console.error(state.message); // ✅ message is accessible
}
}
// Type predicates — custom type guards
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'email' in obj
);
}
// After isUser() returns true, TypeScript knows obj is User
function processPayload(payload: unknown) {
if (isUser(payload)) {
console.log(payload.email); // ✅ fully typed
}
}De
typeof-operator
is het meest fundamentele narrowing-gereedschap. Gecombineerd met
instanceof, de in-operator en discriminant-eigenschapscontroles,
kun je vrijwel elk narrowing-scenario afhandelen zonder any of onveilige
casts te gebruiken.
Samenvatting
TypeScript's typesysteem beloont het goed leren van de fundamenten boven het memoriseren van
obscure functies. Letterlijke typen en gediscrimineerde unions elimineren hele categorieën runtime-bugs.
Generics laten je herbruikbare, type-veilige abstracties schrijven. Hulptypes zoals
Partial, Pick, Omit en ReturnType houden
je typen DRY. En unknown met narrowing geeft je de veiligheid van typen zelfs aan
de randen waar data binnenkomt van de buitenwereld. De beste manier om dit alles te consolideren is
interactief experimenteren — de
TypeScript Playground
laat je elk fragment plakken en de afgeleide typen meteen zien, geen installatie nodig.
Wanneer je werkt met JSON-data in TypeScript-projecten, kunnen de
JSON Formatter en
JSON to TypeScript-tools op deze site automatisch
interface-definities genereren vanuit echte API-payloads.