A generic that exists to look clever costs more than it saves — the real test is whether it catches a bug at compile time that a non-generic version would have let through. The patterns below are the ones that pay for themselves in a real backend: paginated responses, entity-to-DTO mapping, and repository helpers.
A paginated response wrapper, once
Every list endpoint in a real API returns the same shape — items, total count, page, page size — but for a different entity each time. Writing that shape as a generic Paginated<T> means the pagination metadata is defined exactly once, and every endpoint that uses it gets full autocomplete on items without re-declaring the wrapper.
export interface Paginated<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasNextPage: boolean;
}
function paginate<T>(
items: T[],
total: number,
page: number,
pageSize: number,
): Paginated<T> {
return { items, total, page, pageSize, hasNextPage: page * pageSize < total };
}
// Usage — T is inferred, no manual annotation needed
async findProducts(query: GetProductsDto): Promise<Paginated<ProductDto>> {
const [rows, total] = await this.repository.findAndCount(/* ... */);
return paginate(rows.map((r) => r.toDto()), total, query.page, query.pageSize);
}
Constraining generics so misuse fails at compile time
An unconstrained <T> accepts anything, which means a typo in a property access only fails deep inside the function body instead of at the call site. Constraining the generic with extends { id: string } tells the compiler exactly what shape T must have, so a caller passing an entity without an id fails immediately, with a clear message, at the point of the call.
- Prefer a generic function over a generic class when the type only needs to flow through one operation — classes carry the type parameter everywhere, functions only where they are called.
- Use conditional types (
T extends U ? X : Y) sparingly and only when the branching genuinely depends on the input type, not as a way to avoid writing two overloads. - Default type parameters (
<T = unknown>) keep a generic usable without annotation for the common case while still allowing precision when it matters. keyofand mapped types (Partial<T>,Pick<T, K>) usually replace a hand-written generic entirely — reach for the built-in utility type first.- A generic repository helper should still let callers pass entity-specific
whereclauses typed against that entity, not a loosely-typedRecord<string, unknown>.
A generic mapper that keeps DTOs honest
Hand-copying fields from an entity to a DTO drifts the moment someone adds a column to the entity and forgets the DTO — TypeScript will not warn about a DTO that is simply missing a field nobody told it to include. A generic toDto<E, D> helper does not fix that by itself, but pairing it with a DTO class whose constructor destructures the entity forces a compile error the moment a required DTO field has no matching source.
export class ProductDto {
id: string;
name: string;
price: string;
constructor(entity: ProductEntity) {
this.id = entity.id;
this.name = entity.name;
this.price = entity.basePrice;
// Adding a required field here without a matching entity property
// fails to compile — that is the whole point.
}
}
A generic is worth adding when it removes duplication AND narrows what can compile. If it only removes duplication, a plain function without a type parameter usually reads just as well.
Where generics stop being worth it
A single generic repository method that tries to cover every possible find variant across every entity in the codebase — with optional relations, optional filters, optional sorting all threaded through type parameters — usually becomes harder to read than three concrete, entity-specific methods. Generics should reduce the number of concepts a reader has to hold in their head, not multiply them.
A typed event emitter, worked through
A generic event map — a plain interface where each key is an event name and each value is the payload type for that event — lets emit() and on() share one type parameter, so calling emit('order.paid', payload) with the wrong payload shape for order.paid fails to compile instead of failing silently at runtime when a listener destructures a field that was never sent.
interface AppEvents {
'order.paid': { orderId: string; amount: string };
'user.registered': { userId: string; email: string };
}
class TypedEmitter<Events extends Record<string, unknown>> {
private handlers = new Map<keyof Events, Array<(payload: any) => void>>();
on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
const list = this.handlers.get(event) ?? [];
list.push(handler);
this.handlers.set(event, list);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
for (const handler of this.handlers.get(event) ?? []) handler(payload);
}
}
const bus = new TypedEmitter<AppEvents>();
bus.on('order.paid', (payload) => payload.amount); // payload is typed, no cast
bus.emit('order.paid', { orderId: 'ord_1', amount: '1000' }); // checked at compile time
The mapped Events[K] return type is what makes this worth the ceremony: a listener registered for order.paid gets a payload typed exactly as { orderId: string; amount: string } with zero casting, and adding a new event to AppEvents immediately flags every emit call across the codebase that does not yet send it correctly.
Discriminated unions pair naturally with generics
A discriminated union — several object shapes sharing one literal "tag" field with different values — lets TypeScript narrow the type automatically inside an if or switch on that field, no manual type guard needed. Combined with an exhaustiveness check, adding a new variant to the union and forgetting to handle it somewhere becomes a compile error instead of a silent runtime gap.
type DeliveryStatus =
| { kind: 'pending' }
| { kind: 'delivered'; deliveredAt: Date }
| { kind: 'failed'; reason: string };
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function describeStatus(status: DeliveryStatus): string {
switch (status.kind) {
case 'pending': return 'Waiting for delivery';
case 'delivered': return `Delivered at ${status.deliveredAt.toISOString()}`;
case 'failed': return `Failed: ${status.reason}`;
default: return assertNever(status); // compile error if a case is missing
}
}
The moment a fourth variant — say, { kind: 'returned' } — is added to DeliveryStatus, every switch statement missing that case fails to compile at the assertNever call, because status at that point is no longer narrowed to never. That single function, assertNever, is what turns an easy-to-miss manual review item into a build failure.
Reading a generic-heavy type error, without panicking
A deeply generic function that fails to infer correctly often produces an error message several lines long, referencing types that never appear directly in the calling code — the instinct to add as any at the call site to make the red squiggle disappear is almost always the wrong fix, because it silences the exact signal that caught a real mismatch.
The more reliable fix is usually to explicitly annotate the type parameter at the call site (paginate<ProductDto>(...) instead of relying on inference) or to check whether the generic's constraint (extends) is actually narrow enough to describe what the function needs — a constraint that is too loose is the most common reason inference fails in a confusing way.
Conclusion
The generics worth keeping in a real API are the small number that eliminate real duplication while making a real class of bugs impossible to compile: pagination wrappers, constrained repository helpers, and DTO constructors that fail loudly when an entity and a DTO drift apart. Everything past that is usually solved better by a named type or two ordinary functions.

