Back to Blog
TypeScriptGenericsAdvancedTypes

Advanced TypeScript Generics

Master advanced TypeScript generics. From constraints and inference to conditional types and mapped types.

B
Bootspring Team
Engineering
March 4, 2022
8 min read

Generics enable flexible, reusable code while maintaining type safety. Here's how to use advanced generic patterns effectively.

Generic Constraints#

1// Basic constraint 2function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { 3 return obj[key]; 4} 5 6const user = { name: 'John', age: 30 }; 7getProperty(user, 'name'); // string 8getProperty(user, 'age'); // number 9getProperty(user, 'foo'); // Error: 'foo' is not in keyof user 10 11// Multiple constraints with intersection 12interface HasId { 13 id: string; 14} 15 16interface HasTimestamp { 17 createdAt: Date; 18} 19 20function logEntity<T extends HasId & HasTimestamp>(entity: T) { 21 console.log(`${entity.id} created at ${entity.createdAt}`); 22} 23 24// Constructor constraint 25type Constructor<T> = new (...args: any[]) => T; 26 27function createInstance<T>(ctor: Constructor<T>, ...args: any[]): T { 28 return new ctor(...args); 29} 30 31class User { 32 constructor(public name: string) {} 33} 34 35const user = createInstance(User, 'John'); // User type 36 37// Constraint with default 38interface PaginationOptions<T = unknown> { 39 items: T[]; 40 page: number; 41 pageSize: number; 42}

Generic Inference#

1// Infer from function arguments 2function map<T, U>(arr: T[], fn: (item: T) => U): U[] { 3 return arr.map(fn); 4} 5 6// T and U are inferred 7const lengths = map(['a', 'bb', 'ccc'], (s) => s.length); 8// number[] 9 10// Infer from return type 11function createPair<T, U>(first: T, second: U): [T, U] { 12 return [first, second]; 13} 14 15const pair = createPair('hello', 42); // [string, number] 16 17// Infer array element type 18function first<T>(arr: T[]): T | undefined { 19 return arr[0]; 20} 21 22const firstNum = first([1, 2, 3]); // number | undefined 23 24// Infer with conditional types 25type ArrayElement<T> = T extends (infer E)[] ? E : never; 26 27type Num = ArrayElement<number[]>; // number 28type Str = ArrayElement<string[]>; // string 29 30// Infer function parameter types 31type Parameters<T extends (...args: any) => any> = 32 T extends (...args: infer P) => any ? P : never; 33 34type Params = Parameters<(a: string, b: number) => void>; 35// [a: string, b: number]

Generic Classes#

1// Generic collection 2class Stack<T> { 3 private items: T[] = []; 4 5 push(item: T): void { 6 this.items.push(item); 7 } 8 9 pop(): T | undefined { 10 return this.items.pop(); 11 } 12 13 peek(): T | undefined { 14 return this.items[this.items.length - 1]; 15 } 16 17 isEmpty(): boolean { 18 return this.items.length === 0; 19 } 20} 21 22const numberStack = new Stack<number>(); 23numberStack.push(1); 24numberStack.push(2); 25numberStack.pop(); // number | undefined 26 27// Generic class with constraints 28class Repository<T extends { id: string }> { 29 private items: Map<string, T> = new Map(); 30 31 add(item: T): void { 32 this.items.set(item.id, item); 33 } 34 35 get(id: string): T | undefined { 36 return this.items.get(id); 37 } 38 39 getAll(): T[] { 40 return Array.from(this.items.values()); 41 } 42 43 delete(id: string): boolean { 44 return this.items.delete(id); 45 } 46} 47 48interface User { 49 id: string; 50 name: string; 51} 52 53const userRepo = new Repository<User>(); 54userRepo.add({ id: '1', name: 'John' }); 55 56// Static members with generics 57class StaticGeneric { 58 static create<T>(value: T): Box<T> { 59 return new Box(value); 60 } 61} 62 63class Box<T> { 64 constructor(public value: T) {} 65} 66 67const box = StaticGeneric.create(42); // Box<number>

Conditional Types#

1// Basic conditional type 2type IsString<T> = T extends string ? true : false; 3 4type A = IsString<string>; // true 5type B = IsString<number>; // false 6 7// Distributive conditional types 8type ToArray<T> = T extends any ? T[] : never; 9 10type StringOrNumberArray = ToArray<string | number>; 11// string[] | number[] 12 13// Prevent distribution with tuple 14type ToArrayNonDist<T> = [T] extends [any] ? T[] : never; 15 16type Together = ToArrayNonDist<string | number>; 17// (string | number)[] 18 19// Extract and Exclude 20type Extract<T, U> = T extends U ? T : never; 21type Exclude<T, U> = T extends U ? never : T; 22 23type Numbers = Extract<string | number | boolean, number>; 24// number 25 26type NotNumbers = Exclude<string | number | boolean, number>; 27// string | boolean 28 29// Infer in conditional types 30type Flatten<T> = T extends Array<infer U> ? U : T; 31 32type Flattened = Flatten<string[]>; // string 33type NotFlattened = Flatten<string>; // string 34 35// Multiple inferences 36type UnpackPromise<T> = T extends Promise<infer U> 37 ? U extends Promise<infer V> 38 ? V 39 : U 40 : T; 41 42type Result = UnpackPromise<Promise<Promise<string>>>; // string

Mapped Types with Generics#

1// Make all properties optional 2type Partial<T> = { 3 [P in keyof T]?: T[P]; 4}; 5 6// Make all properties required 7type Required<T> = { 8 [P in keyof T]-?: T[P]; 9}; 10 11// Make all properties readonly 12type Readonly<T> = { 13 readonly [P in keyof T]: T[P]; 14}; 15 16// Remove readonly 17type Mutable<T> = { 18 -readonly [P in keyof T]: T[P]; 19}; 20 21// Pick specific keys 22type Pick<T, K extends keyof T> = { 23 [P in K]: T[P]; 24}; 25 26// Omit specific keys 27type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>; 28 29// Transform property types 30type Stringify<T> = { 31 [P in keyof T]: string; 32}; 33 34// Key remapping (TypeScript 4.1+) 35type Getters<T> = { 36 [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]; 37}; 38 39interface Person { 40 name: string; 41 age: number; 42} 43 44type PersonGetters = Getters<Person>; 45// { getName: () => string; getAge: () => number; } 46 47// Filter keys by value type 48type FilterByType<T, ValueType> = { 49 [K in keyof T as T[K] extends ValueType ? K : never]: T[K]; 50}; 51 52interface Mixed { 53 name: string; 54 age: number; 55 active: boolean; 56} 57 58type StringProps = FilterByType<Mixed, string>; 59// { name: string }

Generic Utility Functions#

1// Type-safe object entries 2function entries<T extends object>(obj: T): [keyof T, T[keyof T]][] { 3 return Object.entries(obj) as [keyof T, T[keyof T]][]; 4} 5 6// Type-safe Object.keys 7function keys<T extends object>(obj: T): (keyof T)[] { 8 return Object.keys(obj) as (keyof T)[]; 9} 10 11// Type-safe Object.fromEntries 12function fromEntries<K extends string, V>( 13 entries: [K, V][] 14): Record<K, V> { 15 return Object.fromEntries(entries) as Record<K, V>; 16} 17 18// Deep clone with type preservation 19function deepClone<T>(obj: T): T { 20 if (obj === null || typeof obj !== 'object') { 21 return obj; 22 } 23 24 if (Array.isArray(obj)) { 25 return obj.map(deepClone) as unknown as T; 26 } 27 28 return Object.fromEntries( 29 Object.entries(obj).map(([key, value]) => [key, deepClone(value)]) 30 ) as T; 31} 32 33// Type-safe merge 34function merge<T extends object, U extends object>( 35 target: T, 36 source: U 37): T & U { 38 return { ...target, ...source }; 39}

Variadic Generics#

1// Tuple types with rest elements 2type Concat<T extends unknown[], U extends unknown[]> = [...T, ...U]; 3 4type Result = Concat<[1, 2], [3, 4]>; // [1, 2, 3, 4] 5 6// Function with variadic generics 7function concat<T extends unknown[], U extends unknown[]>( 8 arr1: T, 9 arr2: U 10): [...T, ...U] { 11 return [...arr1, ...arr2]; 12} 13 14const result = concat([1, 2], ['a', 'b']); 15// [number, number, string, string] 16 17// Curry with type preservation 18type Curry<F extends (...args: any[]) => any> = F extends ( 19 arg: infer A, 20 ...rest: infer Rest 21) => infer R 22 ? Rest extends [] 23 ? F 24 : (arg: A) => Curry<(...args: Rest) => R> 25 : never; 26 27function curry<F extends (...args: any[]) => any>(fn: F): Curry<F> { 28 return function curried(...args: any[]): any { 29 if (args.length >= fn.length) { 30 return fn(...args); 31 } 32 return (...more: any[]) => curried(...args, ...more); 33 } as Curry<F>; 34} 35 36const add = (a: number, b: number, c: number) => a + b + c; 37const curriedAdd = curry(add); 38 39curriedAdd(1)(2)(3); // 6

Real-World Patterns#

1// Event emitter with typed events 2type EventMap = { 3 click: { x: number; y: number }; 4 focus: { target: HTMLElement }; 5 submit: { data: FormData }; 6}; 7 8class TypedEventEmitter<T extends Record<string, any>> { 9 private listeners: Partial<{ [K in keyof T]: Set<(data: T[K]) => void> }> = {}; 10 11 on<K extends keyof T>(event: K, callback: (data: T[K]) => void): void { 12 if (!this.listeners[event]) { 13 this.listeners[event] = new Set(); 14 } 15 this.listeners[event]!.add(callback); 16 } 17 18 emit<K extends keyof T>(event: K, data: T[K]): void { 19 this.listeners[event]?.forEach((callback) => callback(data)); 20 } 21} 22 23const emitter = new TypedEventEmitter<EventMap>(); 24emitter.on('click', ({ x, y }) => console.log(x, y)); 25emitter.emit('click', { x: 10, y: 20 }); 26 27// Builder pattern with generics 28class Builder<T extends object = {}> { 29 private obj: T; 30 31 constructor(obj: T = {} as T) { 32 this.obj = obj; 33 } 34 35 with<K extends string, V>( 36 key: K, 37 value: V 38 ): Builder<T & { [key in K]: V }> { 39 return new Builder({ ...this.obj, [key]: value } as T & { [key in K]: V }); 40 } 41 42 build(): T { 43 return this.obj; 44 } 45} 46 47const result = new Builder() 48 .with('name', 'John') 49 .with('age', 30) 50 .with('active', true) 51 .build(); 52// { name: string; age: number; active: boolean }

Best Practices#

Design: ✓ Use constraints to narrow generic types ✓ Let TypeScript infer when possible ✓ Provide defaults for common cases ✓ Use meaningful type parameter names Performance: ✓ Avoid deeply nested generics ✓ Simplify conditional types when possible ✓ Use mapped types for transformations ✓ Consider compile-time cost Readability: ✓ Document complex generic types ✓ Break down complex types into smaller ones ✓ Use type aliases for clarity ✓ Test types with explicit annotations

Conclusion#

Advanced generics enable powerful, reusable abstractions while maintaining type safety. Master constraints for narrowing types, conditional types for branching logic, and mapped types for transformations. The key is balancing flexibility with readability - complex generic types should be well-documented and tested.

Share this article

Help spread the word about Bootspring