Why Generics Feel Hard
Generics look like math. <T>, <T extends K>, <T, U>—it reads like a type system paper, not application code.
But the concept is simple: write code that works with any type, while keeping type safety.
The Problem Generics Solve
Without generics:
function firstElement(arr: number[]): number {
return arr[0];
}
function firstString(arr: string[]): string {
return arr[0];
}
// Duplicate code for every type... or
function first(arr: any[]): any {
return arr[0]; // loses all type safety
}
With generics:
function first<T>(arr: T[]): T {
return arr[0];
}
const num = first([1, 2, 3]); // TypeScript knows: number
const str = first(['a', 'b']); // TypeScript knows: string
const user = first(users); // TypeScript knows: User
One function, full type safety, any type.
Generic Functions
// Identity function
function identity<T>(value: T): T {
return value;
}
// Pair
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const p = pair('hello', 42); // [string, number]
// Map with type inference
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
const lengths = map(['hello', 'world'], s => s.length); // number[]
Generic Constraints
Limit what types T can be:
// T must have a .length property
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest('hello', 'hi'); // works — strings have .length
longest([1, 2, 3], [1, 2]); // works — arrays have .length
longest(10, 20); // Error: number has no .length
// T must be a key of U
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: 'Alice', age: 30 };
const name = getProperty(user, 'name'); // string
const age = getProperty(user, 'age'); // number
getProperty(user, 'email'); // Error: not a key of user
Generic Interfaces and Types
// Generic interface
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
create(data: Omit<T, 'id'>): Promise<T>;
update(id: string, data: Partial<T>): Promise<T>;
delete(id: string): Promise<void>;
}
// Implement for specific types
class UserRepository implements Repository<User> {
async findById(id: string): Promise<User | null> {
return db.users.findUnique({ where: { id } });
}
// ...
}
// Generic type aliases
type Result<T> =
| { success: true; data: T }
| { success: false; error: string };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const user = await db.users.findUnique({ where: { id } });
if (!user) return { success: false, error: 'Not found' };
return { success: true, data: user };
} catch (e) {
return { success: false, error: (e as Error).message };
}
}
// Usage with type narrowing
const result = await fetchUser('123');
if (result.success) {
console.log(result.data.email); // TypeScript knows data is User
} else {
console.log(result.error); // TypeScript knows error is string
}
Generic React Components
// Generic select component
interface SelectProps<T> {
options: T[];
value: T | null;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string;
}
function Select<T>({
options,
value,
onChange,
getLabel,
getValue,
}: SelectProps<T>) {
return (
<select
value={value ? getValue(value) : ''}
onChange={(e) => {
const selected = options.find(o => getValue(o) === e.target.value);
if (selected) onChange(selected);
}}
>
{options.map((option) => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
// Usage — fully typed
<Select<User>
options={users}
value={selectedUser}
onChange={setSelectedUser}
getLabel={(u) => u.name}
getValue={(u) => u.id}
/>
Utility Types (Built-in Generics)
interface User {
id: string;
name: string;
email: string;
password: string;
}
type PublicUser = Omit<User, 'password'>;
// { id, name, email }
type UserUpdate = Partial<Pick<User, 'name' | 'email'>>;
// { name?: string; email?: string }
type ReadonlyUser = Readonly<User>;
// All fields readonly
type UserRecord = Record<string, User>;
// { [key: string]: User }
// Conditional types
type NonNullable<T> = T extends null | undefined ? never : T;
type Awaited<T> = T extends Promise<infer U> ? U : T;
type UserFromPromise = Awaited<Promise<User>>; // User
The mental model: generics are function parameters for your types. function<T> is "this function takes a type parameter T." Once that clicks, generics become readable.
TypeScript patterns, generics, and type utilities baked into a production codebase: Whoff Agents AI SaaS Starter Kit.
Top comments (0)