TypeScript Generics Explained: The Practical Guide
Generics aren't as scary as they look. They're just "types for types."
What Are Generics?
// Without generics — any type, no type safety
function identity(value: any): any {
return value;
}
// With generics — type-safe!
function identity<T>(value: T): T {
return value;
}
const num = identity(42); // type: number
const str = identity('hello'); // type: string
const arr = identity([1, 2, 3]); // type: number[]
// TypeScript INFERS the type from usage!
// You don't have to write identity<number>(42)
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']); // string | undefined
// Merge two objects
function merge<T, U>(a: T, b: U): T & U {
return { ...a, ...b };
}
const result = merge({ name: 'Alex' }, { age: 30 });
// type: { name: string } & { age: number }
// result.name → string ✅
// result.age → number ✅
// result.xyz → TypeScript error ✅
// Map over array with transformation
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
const names = map(
[{ id: 1, name: 'Alex' }, { id: 2, name: 'Sam' }],
user => user.name
);
// type: string[] → ['Alex', 'Sam']
Generic Interfaces
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
// Reuse the same interface for different data types
type UserResponse = ApiResponse<{ id: number; name: string }>;
type PostResponse = ApiResponse<{ id: number; title: string; body: string }>;
const userRes: UserResponse = {
data: { id: 1, name: 'Alex' },
status: 200,
message: 'OK',
timestamp: '2026-05-16T00:00:00Z',
};
// Pagination
interface PaginatedResponse<T> {
data: T[];
page: number;
limit: number;
total: number;
hasMore: boolean;
}
const usersPage: PaginatedResponse<{ id: number; name: string }> = {
data: [{ id: 1, name: 'Alex' }],
page: 1,
limit: 20,
total: 100,
hasMore: true,
};
Generic Classes
class DataStore<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
find(predicate: (item: T) => boolean): T | undefined {
return this.items.find(predicate);
}
getAll(): T[] {
return [...this.items];
}
remove(predicate: (item: T) => boolean): T | undefined {
const index = this.items.findIndex(predicate);
if (index === -1) return undefined;
return this.items.splice(index, 1)[0];
}
}
// Type-safe stores!
const userStore = new DataStore<{ id: number; name: string }>();
userStore.add({ id: 1, name: 'Alex' });
userStore.add({ id: 'wrong', name: 'Sam' }); // Error! id must be number
const postStore = new DataStore<{ title: string; published: boolean }>();
postStore.add({ title: 'Hello', published: true });
Constraining Generics
// T must have an 'id' property
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
findById([{ id: 1, name: 'Alex' }], 1); // Works!
findById([{ name: 'Alex' }], 1); // Error! No 'id' property
// T must have a 'length' property
function logLength<T extends { length: number }>(item: T): void {
console.log(item.length);
}
logLength('hello'); // 5 ✅
logLength([1, 2, 3]); // 3 ✅
logLength(42); // Error! number has no length
// keyof constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: 'Alex', age: 30, email: 'alex@example.com' };
getProperty(user, 'name'); // string
getProperty(user, 'age'); // number
getProperty(user, 'xyz'); // Error! 'xyz' is not a key of user
Generic Utility Types
// Partial — all properties optional
type PartialUser = Partial<{ name: string; age: number; email: string }>;
const update: PartialUser = { name: 'New Name' }; // Only update name
// Required — all properties required
type RequiredUser = Required<{ name?: string; age?: number }>;
// Both name and age are now required
// Pick — select specific properties
type UserPreview = Pick<{ id: number; name: string; email: string; role: string }, 'id' | 'name'>;
// { id: number; name: string }
// Omit — exclude specific properties
type CreateUser = Omit<{ id: number; name: string; email: string }, 'id'>;
// { name: string; email: string }
// Record — key-value type
type Roles = Record<string, string[]>;
const roles: Roles = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
};
// Readonly
type Config = Readonly<{ host: string; port: number }>;
const config: Config = { host: 'localhost', port: 3000 };
config.port = 8080; // Error! Cannot assign to 'port' because it is read-only
Conditional Types
// If-else for types
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// Extract return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Fn = (x: number) => string;
type Result = ReturnType<Fn>; // string
// NonNullable
type Safe<T> = T extends null | undefined ? never : T;
type A = Safe<string | null>; // string
type B = Safe<null>; // never
Real-World Patterns
Type-safe API client
async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
// Usage — fully typed!
const users = await fetchApi<{ id: number; name: string }[]>('/api/users');
const user = await fetchApi<{ id: number; name: string }>('/api/users/1');
Type-safe event emitter
type Events = {
login: { userId: number; timestamp: Date };
logout: { userId: number };
error: { message: string; code: number };
};
class EventEmitter<T extends Record<string, any>> {
private handlers = new Map<keyof T, Set<Function>>();
on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
this.handlers.get(event)?.forEach(fn => fn(data));
}
}
const emitter = new EventEmitter<Events>();
emitter.on('login', (data) => {
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
emitter.on('login', (data) => {
console.log(data.wrong); // Error! 'wrong' doesn't exist on login event
});
Are you using generics in your TypeScript code? What patterns do you use?
Follow @armorbreak for more TypeScript content.
Top comments (0)