TypeScript Generics Explained: The Practical Guide
Generics look scary. They're not. They're just "placeholders for types." Here's everything you need.
What Are Generics?
// Without generics — duplicate code for different types
function identityString(arg: string): string {
return arg;
}
function identityNumber(arg: number): number {
return arg;
}
function identityBoolean(arg: boolean): boolean {
return arg;
}
// With generics — ONE function for ALL types
function identity<T>(arg: T): T {
return arg;
}
identity('hello'); // T is inferred as string
identity(42); // T is inferred as number
identity(true); // T is inferred as boolean
identity<string>('hello'); // Explicit type (rarely needed)
The Mental Model
Generic = Type Variable = "I'll tell you the type later"
<T> = "This function works with any type T"
When you call it, TypeScript figures out what T is
Based on the arguments you pass in
Practical Examples
1. Generic Functions
// First element of array
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
first([1, 2, 3]) // number | undefined
first(['a', 'b', 'c']) // string | undefined
// Merge two objects
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const result = merge({ name: 'Alice' }, { age: 30 });
// { name: string; age: number }
// Filter array
function filter<T>(arr: T[], predicate: (item: T) => boolean): T[] {
return arr.filter(predicate);
}
const adults = filter(
[{ name: 'Alice', age: 30 }, { name: 'Bob', age: 15 }],
person => person.age >= 18
);
// Type: { name: string; age: number }[]
// Promise wrapper
function safeAsync<T>(promise: Promise<T>): Promise<T | null> {
return promise.catch(() => null);
}
const user = await safeAsync(fetchUser(id));
// Type: User | null
2. Generic Interfaces & Types
// API Response wrapper
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
// Usage
type User = { id: number; name: string; email: string };
type Post = { id: number; title: string; content: string };
const userResponse: ApiResponse<User> = {
data: { id: 1, name: 'Alice', email: 'alice@test.com' },
status: 200,
message: 'OK',
timestamp: new Date().toISOString(),
};
const postsResponse: ApiResponse<Post[]> = {
data: [{ id: 1, title: 'Hello', content: 'World' }],
status: 200,
message: 'OK',
timestamp: new Date().toISOString(),
};
// Paginated response
interface PaginatedResponse<T> {
data: T[];
meta: {
page: number;
per_page: number;
total: number;
total_pages: number;
};
}
type PaginatedUsers = PaginatedResponse<User>;
// { data: User[]; meta: { page: number; ... } }
3. Generic Classes
// Data store
class DataStore<T> {
private items: Map<string, T> = new Map();
set(key: string, item: T): void {
this.items.set(key, item);
}
get(key: string): T | undefined {
return this.items.get(key);
}
has(key: string): boolean {
return this.items.has(key);
}
getAll(): T[] {
return Array.from(this.items.values());
}
delete(key: string): boolean {
return this.items.delete(key);
}
}
const userStore = new DataStore<User>();
userStore.set('user1', { id: 1, name: 'Alice', email: 'a@test.com' });
const user = userStore.get('user1'); // Type: User | undefined
const postStore = new DataStore<Post>();
postStore.set('post1', { id: 1, title: 'Hello', content: 'World' });
4. Generic Constraints (extends)
// Constrain T to have a specific property
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: 'Alice', age: 30, email: 'a@test.com' };
getProperty(user, 'name'); // string
getProperty(user, 'age'); // number
getProperty(user, 'xyz'); // ❌ Error! 'xyz' is not a key of user
// Constrain to objects with id property
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
findById(users, 1); // { id: number; name: string } | undefined
// Constrain to classes
class Animal {
constructor(public name: string) {}
speak() { return `${this.name} makes a sound`; }
}
class Dog extends Animal {
speak() { return `${this.name} barks`; }
}
function makeSound<T extends Animal>(animal: T): string {
return animal.speak();
}
makeSound(new Dog('Rex')); // 'Rex barks'
makeSound(new Animal('Cat')); // 'Cat makes a sound'
5. Default Type Parameters
// T defaults to string if not specified
interface Dictionary<T = string> {
[key: string]: T;
}
const strings: Dictionary = { a: 'hello', b: 'world' }; // T = string
const numbers: Dictionary<number> = { a: 1, b: 2 }; // T = number
// Default generic in function
function createArray<T = string>(length: number, value: T): T[] {
return Array(length).fill(value);
}
createArray(3, 'hello'); // ['hello', 'hello', 'hello']
createArray(3, 42); // [42, 42, 42]
createArray(3, true); // [true, true, true]
6. Utility Types with Generics
// These built-in generics are incredibly useful:
// Partial — all properties optional
type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string }
// Required — all properties required
type RequiredUser = Required<PartialUser>;
// { id: number; name: string; email: string }
// Pick — select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: number; name: string }
// Omit — exclude specific properties
type UserWithoutEmail = Omit<User, 'email'>;
// { id: number; name: string }
// Record — object with specific key/value types
type RolePermissions = Record<string, string[]>;
// { admin: string[]; user: string[]; ... }
// Readonly — immutable object
type FrozenUser = Readonly<User>;
// { readonly id: number; readonly name: string; ... }
// Combine them!
type UpdateUserDTO = Partial<Omit<User, 'id'>>;
// All properties optional except id (which is omitted)
Common Patterns
// 1. Type-safe event emitter
type EventHandler<T = void> = T extends void
? () => void
: (data: T) => void;
interface Events {
userCreated: { id: number; name: string };
orderPlaced: { orderId: string; total: number };
error: { message: string; code: number };
ready: void;
}
class TypedEmitter<E extends Record<string, any>> {
private handlers = new Map<string, Set<Function>>();
on<K extends keyof E>(event: K, handler: EventHandler<E[K]>): void {
if (!this.handlers.has(event as string)) {
this.handlers.set(event as string, new Set());
}
this.handlers.get(event as string)!.add(handler);
}
emit<K extends keyof E>(event: K, ...args: E[K] extends void ? [] : [E[K]]): void {
this.handlers.get(event as string)?.forEach(fn => fn(...args));
}
}
// 2. Type-safe API client
interface ApiRoutes {
'GET /users': { response: User[] };
'GET /users/:id': { response: User; params: { id: number } };
'POST /users': { response: User; body: CreateUserDTO };
'DELETE /users/:id': { response: void; params: { id: number } };
}
function apiClient<K extends keyof ApiRoutes>(
route: K,
options?: Omit<ApiRoutes[K], 'response'>
): Promise<ApiRoutes[K]['response']> {
return fetch(route as string, options as any).then(r => r.json());
}
// Fully typed!
const users = await apiClient('GET /users'); // User[]
const user = await apiClient('GET /users/:id', { params: { id: 1 } }); // User
Quick Reference
| Pattern | Syntax | When to Use |
|---|---|---|
| Generic function | fn<T>(arg: T): T |
Function works with any type |
| Multiple types | fn<T, U>(a: T, b: U) |
Different types for different params |
| Constraint | fn<T extends SomeType> |
Type must have certain properties |
| Default type | fn<T = string> |
Common fallback type |
| Generic interface | interface I<T> |
Type varies per implementation |
| Generic class | class C<T> |
Internal data type varies |
| keyof | K extends keyof T |
Constrain to object's keys |
| Utility types |
Partial<T>, Pick<T>
|
Transform existing types |
Are generics clicking for you? What confused you at first?
Follow @armorbreak for more TypeScript content.
Top comments (0)