Back to Blog
TypeScriptGenericsProgrammingTypes

TypeScript Generics: A Practical Guide

Master TypeScript generics. From basic syntax to advanced patterns to real-world use cases.

B
Bootspring Team
Engineering
January 28, 2023
6 min read

Generics enable reusable, type-safe code. They let you write functions and classes that work with any type while maintaining type information.

Basic Generics#

1// Without generics - loses type information 2function firstElement(arr: any[]): any { 3 return arr[0]; 4} 5 6// With generics - preserves type 7function firstElement<T>(arr: T[]): T | undefined { 8 return arr[0]; 9} 10 11const num = firstElement([1, 2, 3]); // number 12const str = firstElement(['a', 'b', 'c']); // string 13 14// Multiple type parameters 15function pair<T, U>(first: T, second: U): [T, U] { 16 return [first, second]; 17} 18 19const p = pair('hello', 42); // [string, number]

Generic Constraints#

1// Constrain to types with specific properties 2interface HasLength { 3 length: number; 4} 5 6function logLength<T extends HasLength>(item: T): T { 7 console.log(item.length); 8 return item; 9} 10 11logLength('hello'); // OK - string has length 12logLength([1, 2, 3]); // OK - array has length 13logLength({ length: 10 }); // OK - object has length 14// logLength(123); // Error - number doesn't have length 15 16// Constrain to object keys 17function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { 18 return obj[key]; 19} 20 21const person = { name: 'Alice', age: 30 }; 22const name = getProperty(person, 'name'); // string 23const age = getProperty(person, 'age'); // number 24// getProperty(person, 'email'); // Error - 'email' not in person

Generic Interfaces and Types#

1// Generic interface 2interface Response<T> { 3 data: T; 4 status: number; 5 message: string; 6} 7 8interface User { 9 id: string; 10 name: string; 11} 12 13const userResponse: Response<User> = { 14 data: { id: '1', name: 'Alice' }, 15 status: 200, 16 message: 'Success', 17}; 18 19// Generic type alias 20type Result<T, E = Error> = 21 | { success: true; data: T } 22 | { success: false; error: E }; 23 24function divide(a: number, b: number): Result<number, string> { 25 if (b === 0) { 26 return { success: false, error: 'Division by zero' }; 27 } 28 return { success: true, data: a / b }; 29} 30 31// Generic with default type 32interface Container<T = string> { 33 value: T; 34} 35 36const strContainer: Container = { value: 'hello' }; // T is string 37const numContainer: Container<number> = { value: 42 };

Generic Classes#

1class Stack<T> { 2 private items: T[] = []; 3 4 push(item: T): void { 5 this.items.push(item); 6 } 7 8 pop(): T | undefined { 9 return this.items.pop(); 10 } 11 12 peek(): T | undefined { 13 return this.items[this.items.length - 1]; 14 } 15 16 isEmpty(): boolean { 17 return this.items.length === 0; 18 } 19} 20 21const numberStack = new Stack<number>(); 22numberStack.push(1); 23numberStack.push(2); 24const top = numberStack.pop(); // number | undefined 25 26// Generic class with constraints 27class KeyValueStore<K extends string | number, V> { 28 private store = new Map<K, V>(); 29 30 set(key: K, value: V): void { 31 this.store.set(key, value); 32 } 33 34 get(key: K): V | undefined { 35 return this.store.get(key); 36 } 37} 38 39const store = new KeyValueStore<string, User>(); 40store.set('user1', { id: '1', name: 'Alice' });

Utility Types with Generics#

1// Built-in utility types use generics 2 3// Partial - make all properties optional 4type PartialUser = Partial<User>; 5// { id?: string; name?: string; } 6 7// Required - make all properties required 8type RequiredUser = Required<PartialUser>; 9 10// Pick - select specific properties 11type UserName = Pick<User, 'name'>; 12// { name: string; } 13 14// Omit - exclude properties 15type UserWithoutId = Omit<User, 'id'>; 16// { name: string; } 17 18// Record - create object type with keys and values 19type UserMap = Record<string, User>; 20 21// Custom utility types 22type Nullable<T> = T | null; 23type Optional<T> = T | undefined; 24 25type DeepPartial<T> = { 26 [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]; 27}; 28 29type DeepReadonly<T> = { 30 readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]; 31};

Generic Functions in Practice#

1// API response handler 2async function fetchData<T>(url: string): Promise<T> { 3 const response = await fetch(url); 4 if (!response.ok) { 5 throw new Error(`HTTP error: ${response.status}`); 6 } 7 return response.json() as Promise<T>; 8} 9 10const users = await fetchData<User[]>('/api/users'); 11const post = await fetchData<Post>('/api/posts/1'); 12 13// Type-safe event emitter 14class TypedEventEmitter<Events extends Record<string, any>> { 15 private listeners = new Map<keyof Events, Set<Function>>(); 16 17 on<K extends keyof Events>(event: K, listener: (data: Events[K]) => void): void { 18 if (!this.listeners.has(event)) { 19 this.listeners.set(event, new Set()); 20 } 21 this.listeners.get(event)!.add(listener); 22 } 23 24 emit<K extends keyof Events>(event: K, data: Events[K]): void { 25 this.listeners.get(event)?.forEach((listener) => listener(data)); 26 } 27} 28 29interface AppEvents { 30 userLogin: { userId: string; timestamp: Date }; 31 orderPlaced: { orderId: string; total: number }; 32} 33 34const emitter = new TypedEventEmitter<AppEvents>(); 35 36emitter.on('userLogin', (data) => { 37 console.log(data.userId); // TypeScript knows this is string 38}); 39 40emitter.emit('orderPlaced', { orderId: '123', total: 99.99 });

Conditional Types#

1// Type that depends on condition 2type IsString<T> = T extends string ? true : false; 3 4type A = IsString<string>; // true 5type B = IsString<number>; // false 6 7// Extract types from generics 8type ArrayElement<T> = T extends (infer E)[] ? E : never; 9 10type NumArray = ArrayElement<number[]>; // number 11type StrArray = ArrayElement<string[]>; // string 12 13// Function return type 14type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never; 15 16function greet(name: string): string { 17 return `Hello, ${name}`; 18} 19 20type GreetReturn = ReturnType<typeof greet>; // string 21 22// Exclude and Extract 23type Exclude<T, U> = T extends U ? never : T; 24type Extract<T, U> = T extends U ? T : never; 25 26type Numbers = 1 | 2 | 3 | 4 | 5; 27type SmallNumbers = Extract<Numbers, 1 | 2>; // 1 | 2 28type LargeNumbers = Exclude<Numbers, 1 | 2>; // 3 | 4 | 5

Mapped Types#

1// Transform all properties 2type Readonly<T> = { 3 readonly [P in keyof T]: T[P]; 4}; 5 6type Mutable<T> = { 7 -readonly [P in keyof T]: T[P]; 8}; 9 10// Add suffix to all keys 11type Getters<T> = { 12 [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P]; 13}; 14 15interface Person { 16 name: string; 17 age: number; 18} 19 20type PersonGetters = Getters<Person>; 21// { getName: () => string; getAge: () => number; } 22 23// Filter properties by type 24type StringKeys<T> = { 25 [K in keyof T]: T[K] extends string ? K : never; 26}[keyof T]; 27 28type PersonStringKeys = StringKeys<Person>; // 'name'

Best Practices#

1// DO: Use descriptive type parameter names for complex generics 2interface Repository<TEntity, TId = string> { 3 findById(id: TId): Promise<TEntity | null>; 4 save(entity: TEntity): Promise<TEntity>; 5} 6 7// DO: Constrain when you need specific properties 8function merge<T extends object, U extends object>(a: T, b: U): T & U { 9 return { ...a, ...b }; 10} 11 12// DON'T: Over-constrain 13// Bad: Too restrictive 14function bad<T extends { id: string; name: string; email: string }>(obj: T) {} 15 16// Good: Only require what you need 17function good<T extends { id: string }>(obj: T) {} 18 19// DO: Use defaults for common cases 20interface ApiResponse<T, E = Error> { 21 data?: T; 22 error?: E; 23}

Conclusion#

Generics are essential for writing reusable, type-safe TypeScript code. Start with simple type parameters, add constraints when needed, and use utility types to transform existing types. The type system becomes a powerful tool for catching errors at compile time.

Share this article

Help spread the word about Bootspring