Every TypeScript developer has copy-pasted generic syntax from somewhere without fully understanding it. You see `<T>` and you think 'okay, T is the type, whatever' and move on. Then six months later you need to write your own generic function and you're staring at a blank screen wondering why the compiler is yelling at you.
We've been there. Generics clicked for Stefan after writing a data fetching utility for the third time because he kept hardcoding the return type. Let's skip the theoretical textbook explanation and go straight to the patterns you'll actually use.
What Generics Actually Are (One Paragraph, Then We Move On)
Generics let you write functions, types, and classes that work with any type while still being type-safe. Without generics, you're choosing between `any` (type safety out the window) or writing the same function five times for five different types. Generics let you say 'I don't know what type this will be yet, but once you tell me, I'll enforce it consistently.' That's it. Now let's look at real code.
The API Response Wrapper — Your First Real Generic
Every app that talks to an API deals with responses that look like this: success flag, maybe some data, maybe an error message. Without generics, you end up with `any` everywhere or a dozen different response types. Here's the generic version:
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string };
// Usage — TypeScript knows exactly what's in `data`
async function fetchUser(id: string): Promise<ApiResponse<User>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
return { success: false, error: `HTTP ${res.status}` };
}
const data: User = await res.json();
return { success: true, data };
} catch (e) {
return { success: false, error: 'Network error' };
}
}
// At the call site:
const result = await fetchUser('123');
if (result.success) {
// TypeScript knows result.data is User here
console.log(result.data.email);
} else {
// TypeScript knows result.error is string here
console.error(result.error);
}This pattern is everywhere in our codebase. The discriminated union combined with a generic type parameter means you get proper narrowing at the call site — no casting, no `as User`, no crossing fingers. TypeScript knows what `data` is because you told it once, at the type definition level.
Generic Functions — Making Utility Code Actually Reusable
Say you want a function that groups an array of objects by a key. Without generics, you're either writing it specifically for one type or using `any`. With generics, you write it once:
function groupBy<T, K extends keyof T>(
items: T[],
key: K
): Record<string, T[]> {
return items.reduce((acc, item) => {
const groupKey = String(item[key]);
if (!acc[groupKey]) {
acc[groupKey] = [];
}
acc[groupKey].push(item);
return acc;
}, {} as Record<string, T[]>);
}
// Works with users
type User = { id: string; role: 'admin' | 'user'; name: string };
const users: User[] = [
{ id: '1', role: 'admin', name: 'Robert' },
{ id: '2', role: 'user', name: 'Stefan' },
{ id: '3', role: 'user', name: 'Ana' },
];
const byRole = groupBy(users, 'role');
// byRole['admin'] => User[]
// groupBy(users, 'nonexistent') => TypeScript error ✓
// Works with orders too
type Order = { id: string; status: 'pending' | 'shipped'; total: number };
const orders: Order[] = [/* ... */];
const byStatus = groupBy(orders, 'status');The `K extends keyof T` part is doing the heavy lifting here. It constrains K to only be a valid key of whatever T ends up being. So if you pass a users array, TypeScript knows the valid keys are 'id', 'role', and 'name' — and will error if you try anything else. This is the constraint pattern and it's one of the most useful things generics offer.
Constrained Generics — When T Needs to Be More Than 'Anything'
Pure unconstrained generics are actually rare in practice. Most of the time you need your generic type to at least have certain properties. This is where `extends` comes in as a constraint, not inheritance:
// Anything that has an id field can be cached
type WithId = { id: string };
class SimpleCache<T extends WithId> {
private store = new Map<string, T>();
set(item: T): void {
this.store.set(item.id, item);
}
get(id: string): T | undefined {
return this.store.get(id);
}
getAll(): T[] {
return Array.from(this.store.values());
}
}
// Works perfectly
type Product = { id: string; name: string; price: number };
const productCache = new SimpleCache<Product>();
productCache.set({ id: 'p1', name: 'Widget', price: 9.99 });
const product = productCache.get('p1');
// product is Product | undefined — fully typed
// TypeScript blocks this:
type BadItem = { name: string }; // no id field
// const badCache = new SimpleCache<BadItem>(); // Error!
// Property 'id' is missing in type 'BadItem'We use this exact pattern for an in-memory cache in a rate limiter. The constraint makes the class safe to use with any entity type as long as it has an id — which is basically everything in a database-backed app.
The ReturnType and Parameters Utilities — Generics in the Standard Library
TypeScript ships with a bunch of built-in generic utility types. Most developers use `Partial<T>` and `Required<T>` but stop there. The ones that saved us the most time are `ReturnType` and `Parameters`:
// You have a function but need its return type as a type
async function getSubscriptionStatus(userId: string) {
// imagine complex logic here
return {
plan: 'pro' as 'free' | 'pro' | 'enterprise',
renewsAt: new Date(),
seats: 5,
};
}
// Instead of manually defining this type separately:
type SubscriptionStatus = Awaited<ReturnType<typeof getSubscriptionStatus>>;
// SubscriptionStatus is now { plan: 'free' | 'pro' | 'enterprise', renewsAt: Date, seats: number }
// Parameters is great for wrapping functions
function withLogging<T extends (...args: any[]) => any>(
fn: T,
label: string
): (...args: Parameters<T>) => ReturnType<T> {
return (...args: Parameters<T>) => {
console.log(`[${label}] called with`, args);
const result = fn(...args);
console.log(`[${label}] returned`, result);
return result;
};
}
function add(a: number, b: number): number {
return a + b;
}
const loggedAdd = withLogging(add, 'add');
loggedAdd(2, 3); // TypeScript knows args are (number, number) and return is numberThe `Awaited<ReturnType<typeof fn>>` pattern is especially useful when you're deriving types from async functions that you'd otherwise have to maintain in two places. Change the function's return shape and the type follows automatically.
Generic React Components — Props That Actually Make Sense
This is where generics pay off massively for frontend work. A data table, a select dropdown, a combobox — all of these components work with different data types but need to be type-safe about it:
import { type ReactNode } from 'react';
type Column<T> = {
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => ReactNode;
};
type DataTableProps<T extends Record<string, unknown>> = {
data: T[];
columns: Column<T>[];
rowKey: keyof T;
};
function DataTable<T extends Record<string, unknown>>({
data,
columns,
rowKey,
}: DataTableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={String(row[rowKey])}>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render
? col.render(row[col.key], row)
: String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// Usage — fully type-safe column definitions
type Invoice = { id: string; amount: number; status: 'paid' | 'pending' };
<DataTable<Invoice>
data={invoices}
rowKey="id"
columns={[
{ key: 'id', header: 'Invoice ID' },
{ key: 'amount', header: 'Amount', render: (v) => `$${v}` },
// { key: 'nonexistent', header: 'Bad' } // TypeScript error ✓
]}
/>Now your column definitions are validated against your data type. If Invoice loses the `amount` field, every column referencing it breaks at compile time, not in production at 2am. We've caught more than one schema migration bug this way.
The goal isn't to be clever with generics — it's to write it once and have the compiler catch mismatches before your users do. If your generic is hard to read, it's probably doing too much.
Common Mistakes That Will Bite You
- Using `any` as a constraint (`T extends any`) — this defeats the purpose entirely, just use `any` directly and be honest about it
- Overcomplicating with multiple type parameters when one would do — if you have `<T, U, V>` and can't explain each one in a sentence, step back
- Forgetting `extends` for constraints — `<T extends object>` vs `<T>` is the difference between 'any object' and 'literally any value including primitives'
- Not using `Awaited<>` when working with async generics — `ReturnType<typeof asyncFn>` gives you `Promise<X>`, not `X`
- Creating generic types that are just worse versions of built-in utility types — check `Partial`, `Required`, `Pick`, `Omit`, `Record` before rolling your own
When to NOT Use Generics
This gets glossed over in most tutorials. Generics have a cognitive overhead cost. If a function is only ever going to be called with one type, just use that type. If you're writing a one-off data transformation for a specific feature, a generic version is over-engineering. We've seen codebases where someone went generic-crazy and the types are so abstract nobody can follow what's happening.
A good rule we use: if you're writing the same function for the second time with a different type, that's when you reach for a generic. Not before. The duplication is the signal. Abstracting prematurely creates complexity that might never pay off.
Our templates at peal.dev use generics exactly like this — sparingly but deliberately, in the shared utilities and component library where the same patterns repeat across every project. Things like the API response wrapper, the paginated list type, and the generic form field component are all in there ready to go.
The Cheat Sheet You'll Actually Use
// 1. Basic generic function
function identity<T>(value: T): T { return value; }
// 2. Constrained generic (T must have these properties)
function getDisplayName<T extends { firstName: string; lastName: string }>(user: T): string {
return `${user.firstName} ${user.lastName}`;
}
// 3. Keyof constraint (K must be a valid key of T)
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map(item => item[key]);
}
// 4. Generic with default type
type PaginatedResponse<T = unknown> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
// 5. Conditional generic type
type NonNullable<T> = T extends null | undefined ? never : T;
// 6. Infer in conditional types (advanced but useful)
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type Result = UnpackPromise<Promise<User>>; // Result is User
// 7. Generic with multiple constraints using intersection
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}Generics aren't magic — they're a way to delay the decision of what type something is until you have more information. Once you internalize that, the syntax stops being intimidating and starts being useful. The `extends` keyword for constraints, `keyof` for property access safety, and the utility types like `ReturnType` and `Awaited` will cover 90% of what you need in a real codebase. The other 10% you'll figure out when you need it.
