Als je al een tijdje TypeScript schrijft maar generics nog steeds aanvoelen als iets dat je van Stack Overflow kopieert zonder het volledig te begrijpen, is dit het artikel om dat te veranderen.
Generics zijn de functionaliteit die TypeScript van "JavaScript met type-annotaties" naar een
oprecht expressief typesysteem tilt. Zodra ze klikken, zie je ze overal — en je grijpt er
van nature naar in plaats van standaard naar any te gaan als het lastig wordt. Het
generics-hoofdstuk van het TypeScript Handbook
behandelt de volledige spec; dit artikel richt zich op de patronen die je in echte codebases daadwerkelijk gebruikt.
Het Probleem met any
Generics bestaan om een specifiek probleem op te lossen: je wilt een functie of datastructuur
laten werken met meerdere typen, maar je wilt geen type-informatie kwijtraken in het proces.
De naïeve oplossing is any — en dat ziet er prima uit totdat je beseft wat je hebt opgegeven:
// With any — TypeScript has no idea what comes out
function identity(arg: any): any {
return arg;
}
const result = identity('hello');
// result is typed as 'any' — you've lost the string type
// TypeScript won't catch this:
result.toFixed(2); // no error at compile time, crashes at runtimeDe type-informatie gaat erin, maar komt er niet meer uit. Wat je ook doet met
result is volledig ongecontroleerd. Dat is geen TypeScript-codebase — dat is JavaScript
met extra stappen. Generics lossen dit op door je te laten zeggen: "Ik weet het exacte type nog niet, maar
wat er ingaat moet er met hetzelfde type uitkomen."
// With a generic — T flows through the function
function identity<T>(arg: T): T {
return arg;
}
const result = identity('hello');
// result is typed as 'string' ✅
result.toFixed(2); // ❌ TypeScript catches this: Property 'toFixed' does not exist on type 'string'
const count = identity(42);
// count is typed as 'number' ✅De <T> declareert een typeparameter — denk eraan als een variabele voor
typen. TypeScript leidt af wat T is uit het argument dat je doorgeeft, dus je hoeft het bijna
nooit expliciet op te schrijven. identity<string>('hello') werkt, maar ook
identity('hello') — TypeScript berekent het zelf.
Generieke Functies in de Praktijk
De identiteitsfunctie is het canonieke leervoorbeeld maar niet iets dat je in productie zou schrijven. Dit zijn de soorten generieke functies die echt voorkomen in echte codebases:
// A type-safe array first/last helper
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
const firstUser = first(users); // UserProfile | undefined
const lastOrder = last(orders); // Order | undefined
const firstNum = first([1, 2, 3]); // number | undefined
// Group array items by a key
function groupBy<T, K extends string | number>(
items: T[],
getKey: (item: T) => K
): Record<K, T[]> {
return items.reduce((acc, item) => {
const key = getKey(item);
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {} as Record<K, T[]>);
}
// TypeScript infers T as Order and K as string
const byStatus = groupBy(orders, order => order.status);
// byStatus: Record<string, Order[]>
// byStatus['pending'] is Order[] ✅Let op hoe TypeScript T en K automatisch afleidt uit de manier waarop je
de functie aanroept. Je krijgt volledige typeveiligheid op de retourwaarde zonder één expliciete
type-annotatie op de aanroeplocatie te schrijven. Dat is de beloning.
Generieke Interfaces en Typen
Hier worden generics onmisbaar voor alledaags werk. Elke codebase die communiceert met een API heeft uiteindelijk een handvol generieke wrapper-typen nodig. Dit zijn de typen die je constant zult zien (en schrijven):
// API response wrapper — used across every endpoint
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
// Paginated list response
interface PaginatedResult<T> {
items: T[];
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
// Using them with concrete types
interface UserProfile {
id: number;
name: string;
email: string;
avatarUrl: string;
}
interface Order {
id: number;
userId: number;
total: number;
status: 'pending' | 'shipped' | 'delivered';
}
// The return types are fully typed — no casting needed downstream
async function getUser(id: number): Promise<ApiResponse<UserProfile>> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
async function listOrders(page: number): Promise<PaginatedResult<Order>> {
const res = await fetch(`/api/orders?page=${page}`);
return res.json();
}
const response = await getUser(1);
response.data.email; // ✅ string
response.data.avatarUrl; // ✅ string
const result = await listOrders(1);
result.items[0].status; // ✅ 'pending' | 'shipped' | 'delivered'
result.totalPages; // ✅ numberUserProfile, Order) automatisch genereren uit een voorbeeldrespons. Je
plugt die vervolgens in je generieke wrappers.Generieke Beperkingen
Soms wil je meerdere typen accepteren, maar niet absoluut elk type. Generieke beperkingen
laten je specificeren wat een typeparameter moet hebben. De syntaxis is
T extends SomeType — wat betekent "T moet toewijsbaar zijn aan SomeType":
// Without constraint — TypeScript doesn't know T has an id property
function findById<T>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id); // ❌ Property 'id' does not exist on type 'T'
}
// With constraint — T must have at least an id: number field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id); // ✅
}
// Works with any type that has id: number
const user = findById(users, 42); // UserProfile | undefined
const order = findById(orders, 101); // Order | undefined
// Another common constraint — T must be an object (excludes primitives)
function mergeDefaults<T extends object>(partial: Partial<T>, defaults: T): T {
return { ...defaults, ...partial };
}
// T must have a name: string and email: string
function formatContact<T extends { name: string; email: string }>(contact: T): string {
return `${contact.name} <${contact.email}>`;
}
// Works on any object with those two fields — UserProfile, Employee, whatever
formatContact({ name: 'Alice', email: '[email protected]', role: 'admin' }); // ✅De beperking vergrendelt T niet exact aan { id: number } —
het betekent dat T minimaal die vorm moet hebben. Dus een volledige
UserProfile met tien velden doorgeven is prima. Dit is structureel typen in actie, wat
een van de krachtigste en
goed gedocumenteerde
functies van TypeScript is.
De keyof Beperking
Een van de nuttigste generieke patronen in de TypeScript standaardbibliotheek en echte
codebases is het combineren van generics met keyof. Dit laat je functies schrijven die
een eigenschapsnaam van een objecttype accepteren en garanderen dat het retourtype overeenkomt met die eigenschap.
// keyof T is the union of all keys of T as string literals
// K extends keyof T means K must be one of those keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: UserProfile = {
id: 1,
name: 'Alice',
email: '[email protected]',
avatarUrl: 'https://example.com/avatar.png'
};
const name = getProperty(user, 'name'); // string ✅
const id = getProperty(user, 'id'); // number ✅
const email = getProperty(user, 'email'); // string ✅
getProperty(user, 'password'); // ❌ Argument of type '"password"' is not assignable to
// parameter of type 'keyof UserProfile'
// Real use case: a generic sort function
function sortBy<T>(items: T[], key: keyof T): T[] {
return [...items].sort((a, b) => {
const av = a[key];
const bv = b[key];
return av < bv ? -1 : av > bv ? 1 : 0;
});
}
const byName = sortBy(users, 'name'); // ✅ sorted by name
const byId = sortBy(users, 'id'); // ✅ sorted by id
sortBy(users, 'nonexistent'); // ❌ caught at compile timeStandaard Typeparameters
Net zoals functieparameters standaardwaarden kunnen hebben, kunnen typeparameters dat ook. Dit is handig wanneer je een generiek type hebt dat bijna altijd met een specifiek type wordt gebruikt, maar je de flexibiliteit wilt bewaren voor de gevallen dat dat niet zo is.
// Default type parameter — T defaults to string if not specified
interface Cache<T = string> {
get(key: string): T | undefined;
set(key: string, value: T, ttlMs?: number): void;
delete(key: string): void;
clear(): void;
}
// Without specifying T — uses the default (string)
declare const stringCache: Cache;
const val = stringCache.get('theme'); // string | undefined
// Specifying a different T
declare const userCache: Cache<UserProfile>;
const user2 = userCache.get('user:42'); // UserProfile | undefined
// Another useful example: an event emitter with a typed payload
interface TypedEvent<TPayload = void> {
subscribe(handler: (payload: TPayload) => void): () => void;
emit(payload: TPayload): void;
}
// Events with no payload — default void keeps the API clean
const appReady: TypedEvent = { /* ... */ };
appReady.emit(); // no argument needed
// Events with a payload
const userLoggedIn: TypedEvent<{ userId: number; timestamp: Date }> = { /* ... */ };
userLoggedIn.emit({ userId: 42, timestamp: new Date() });Ingebouwde Generieke Utility-typen
TypeScript wordt geleverd met een set ingebouwde utility-typen die allemaal zijn geïmplementeerd met de generics die je hierboven hebt geleerd. Begrijpen hoe je ze gebruikt is een van de meest praktische TypeScript-vaardigheden die je kunt hebben. Dit is de kernset met echte voorbeelden die het uitvoertype tonen:
interface UserProfile {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'viewer';
passwordHash: string;
createdAt: Date;
}
// Partial<T> — all fields become optional (great for PATCH payloads)
type UpdateUserPayload = Partial<UserProfile>;
// { id?: number; name?: string; email?: string; role?: ...; ... }
// Required<T> — all fields become required (reverse of Partial)
interface DraftConfig {
apiUrl?: string;
timeout?: number;
maxRetries?: number;
}
type ResolvedConfig = Required<DraftConfig>;
// { apiUrl: string; timeout: number; maxRetries: number }
// Pick<T, K> — keep only the named fields
type UserSummary = Pick<UserProfile, 'id' | 'name' | 'email'>;
// { id: number; name: string; email: string }
// Use in list views — only ship what the UI needs
// Omit<T, K> — exclude the named fields
type PublicUserProfile = Omit<UserProfile, 'passwordHash'>;
// { id: number; name: string; email: string; role: ...; createdAt: Date }
// Safe to include in API responses
// Record<K, V> — typed dictionary / map
type PermissionMap = Record<UserProfile['role'], string[]>;
// { admin: string[]; user: string[]; viewer: string[] }
const permissions: PermissionMap = {
admin: ['read', 'write', 'delete', 'admin'],
user: ['read', 'write'],
viewer: ['read']
};
// ReturnType<T> — infer what a function returns (keeps types in sync automatically)
function buildUserSession(user: UserProfile) {
return {
token: crypto.randomUUID(),
userId: user.id,
role: user.role,
expiresAt: new Date(Date.now() + 3_600_000)
};
}
type UserSession = ReturnType<typeof buildUserSession>;
// { token: string; userId: number; role: 'admin' | 'user' | 'viewer'; expiresAt: Date }
// Change buildUserSession and UserSession updates automatically ✅Als je bestaand JavaScript naar TypeScript converteert en deze typen van scratch bouwt, kan de JS to TypeScript tool je helpen de begintypen op te zetten zodat je utility-typen erbovenop kunt lagen.
Een Realistisch End-to-End Voorbeeld: Getypeerde API Client
Dit is het patroon waar ik elke keer naar grijp als ik een getypeerde API-laag bouw. Het combineert generieke functies, generieke interfaces en utility-typen in iets dat je end-to-end typeveiligheid geeft van de fetch-aanroep helemaal naar het component dat de data verbruikt:
// The response envelope — wraps every API response
interface ApiResponse<T> {
data: T;
meta: {
requestId: string;
duration: number;
};
}
// Error envelope — what the API sends on failure
interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
// The generic fetch wrapper
async function fetchApi<T>(
url: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options
});
if (!res.ok) {
const err: ApiError = await res.json();
throw new Error(`[${err.code}] ${err.message}`);
}
return res.json();
}
// Typed endpoint functions — T is set at each call site
async function getUser(id: number): Promise<UserProfile> {
const { data } = await fetchApi<UserProfile>(`/api/users/${id}`);
return data;
}
async function listOrders(userId: number, page = 1): Promise<PaginatedResult<Order>> {
const { data } = await fetchApi<PaginatedResult<Order>>(
`/api/users/${userId}/orders?page=${page}`
);
return data;
}
async function createOrder(
payload: Pick<Order, 'userId' | 'total'>
): Promise<Order> {
const { data } = await fetchApi<Order>('/api/orders', {
method: 'POST',
body: JSON.stringify(payload)
});
return data;
}
// Usage — fully typed, no casting anywhere
const user = await getUser(42);
console.log(user.email); // string ✅
const orders = await listOrders(42);
orders.items[0].status; // 'pending' | 'shipped' | 'delivered' ✅
orders.totalPages; // number ✅
const newOrder = await createOrder({ userId: 42, total: 99.99 });
newOrder.id; // number ✅Veelgemaakte Fouten
Een paar patronen om te vermijden zodra je comfortabel bent met generics:
anygebruiken binnen een generic. Als jefunction wrap<T>(val: T): anyschrijft, heb je het doel tenietgedaan. Het hele punt is dat het type doorgaat —anygebruiken als het retourtype of in de implementatie betekent dat TypeScript het niet meer kan bijhouden.- Over-beperken met specifieke typen. Het schrijven van
function process<T extends UserProfile>wanneer je alleenT extends { id: number }nodig hebt is te restrictief. Gebruik de minimale beperking die je implementatie laat werken — zo blijft de functie herbruikbaar voor verschillende typen die toevallig dezelfde vorm hebben. - Naar generics grijpen wanneer een unie zou volstaan. Als een functie ofwel een
stringofwel eennumberaccepteert en de logica voor elk verschilt, isfunction f(arg: string | number)met een type guard binnenin netter dan een generic. Generics blinken uit wanneer dezelfde logica van toepassing is op alle typevarianten. - Te veel typeparameters. Als je jezelf betrapt op het schrijven van
<T, U, V, W>, stap terug. Dat is gewoonlijk een teken dat de functie te veel doet, of de typen als één interface uitgedrukt kunnen worden. De TypeScript compiler broncode zelf is een goede referentie — zelfs complexe utilities hebben zelden meer dan 2–3 typeparameters nodig.
Samenvatting
Generics zijn het punt waarop TypeScript stopt met "geannoteerd JavaScript" te zijn en een
oprecht krachtig typesysteem wordt. Het kernidee is eenvoudig: leg het type vast als parameter
zodat het door je functie of interface stroomt zonder informatie te verliezen. Daarvandaan laten beperkingen
je vernauwen wat acceptabel is, geeft keyof je typeveilige eigenschapstoegang, en
behandelen de ingebouwde utility-typen (Partial, Pick, Omit,
Record) de patronen die je anders met de hand zou typen. De beste volgende stap
is een echt project openen, een functie vinden waar je any gebruikte omdat je geen
andere manier kende, en die vervangen door een generic. Het
TypeScript Handbook over generics
en de
utility-typen referentie
zijn de twee pagina's die het bookmarken waard zijn. En als je interfaces bouwt van echte API-payloads,
kan JSON to TypeScript de concrete typen genereren —
dan is het alleen nog een kwestie van die in je generieke utility-typen wikkelen zoals
ApiResponse<T> en PaginatedResult<T> voor volledige
end-to-end typeveiligheid.