Advanced Generics & Constraints
Generics are one of TypeScript’s most powerful features, enabling you to write reusable, type-safe code. This guide explores advanced generic patterns that will elevate your TypeScript skills to expert level.
Understanding Generic Constraints
Basic Constraints
Generic constraints allow you to limit the types that can be used with your generics:
// Basic constraint - T must have a length propertyinterface Lengthwise { length: number;}
function logLength<T extends Lengthwise>(arg: T): T { console.log(arg.length); return arg;}
logLength("hello"); // ✅ Works - strings have lengthlogLength([1, 2, 3]); // ✅ Works - arrays have lengthlogLength({ length: 10, value: 3 }); // ✅ Works - object has length// logLength(123); // ❌ Error - numbers don't have lengthKeyof Constraints
Use keyof to constrain generics to object keys:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key];}
const person = { name: "John", age: 30, email: "john@example.com" };
const name = getProperty(person, "name"); // Type: stringconst age = getProperty(person, "age"); // Type: number// const invalid = getProperty(person, "invalid"); // ❌ ErrorMultiple Constraints
Combine multiple constraints for more specific type requirements:
interface Serializable { serialize(): string;}
interface Timestamped { timestamp: Date;}
function processData<T extends Serializable & Timestamped>(data: T): string { return `${data.timestamp.toISOString()}: ${data.serialize()}`;}
class LogEntry implements Serializable, Timestamped { constructor( public message: string, public timestamp: Date = new Date() ) {}
serialize(): string { return JSON.stringify({ message: this.message, timestamp: this.timestamp }); }}
const entry = new LogEntry("System started");console.log(processData(entry)); // Works perfectlyConditional Types
Conditional types enable type-level logic based on type relationships:
Basic Conditional Types
type IsString<T> = T extends string ? true : false;
type Test1 = IsString<string>; // truetype Test2 = IsString<number>; // falsetype Test3 = IsString<"hello">; // trueDistributive Conditional Types
When applied to union types, conditional types distribute over each member:
type ToArray<T> = T extends any ? T[] : never;
type StringOrNumberArray = ToArray<string | number>;// Result: string[] | number[]
// Non-distributive version (using tuple)type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;
type Combined = ToArrayNonDistributive<string | number>;// Result: (string | number)[]Inferring Types
Use infer to extract types within conditional types:
// Extract return type of a functiontype ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type FuncReturn = ReturnType<() => string>; // stringtype AsyncReturn = ReturnType<() => Promise<number>>; // Promise<number>
// Extract array element typetype ArrayElement<T> = T extends (infer U)[] ? U : never;
type StringElement = ArrayElement<string[]>; // stringtype NumberElement = ArrayElement<number[]>; // number
// Extract promise value typetype PromiseValue<T> = T extends Promise<infer U> ? U : T;
type AsyncString = PromiseValue<Promise<string>>; // stringtype SyncNumber = PromiseValue<number>; // numberAdvanced Generic Patterns
Generic Factories
Create type-safe factory functions:
interface Constructable<T = {}> { new (...args: any[]): T;}
class BaseEntity { id: string = Math.random().toString(36); createdAt: Date = new Date();}
function createFactory<T extends BaseEntity>( ctor: Constructable<T>) { return { create: (...args: any[]): T => new ctor(...args), createMany: (count: number, ...args: any[]): T[] => Array.from({ length: count }, () => new ctor(...args)) };}
class User extends BaseEntity { constructor(public name: string, public email: string) { super(); }}
class Product extends BaseEntity { constructor(public title: string, public price: number) { super(); }}
const userFactory = createFactory(User);const productFactory = createFactory(Product);
const user = userFactory.create("John", "john@example.com"); // Type: Userconst users = userFactory.createMany(5, "Jane", "jane@example.com"); // Type: User[]Higher-Order Type Functions
Create functions that operate on types:
// Type-level function compositiontype Compose<F, G> = F extends (arg: infer A) => infer B ? G extends (arg: B) => infer C ? (arg: A) => C : never : never;
type AddOne = (x: number) => number;type ToString = (x: number) => string;
type AddOneThenToString = Compose<AddOne, ToString>; // (x: number) => string
// Recursive type operationstype DeepReadonly<T> = { readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];};
interface User { name: string; profile: { bio: string; settings: { theme: string; notifications: boolean; }; };}
type ReadonlyUser = DeepReadonly<User>;// All properties and nested properties are readonlyGeneric Builders
Implement the builder pattern with generics:
class QueryBuilder<T> { private conditions: string[] = []; private selectFields: (keyof T)[] = []; private orderByField?: keyof T;
select<K extends keyof T>(...fields: K[]): QueryBuilder<Pick<T, K>> { this.selectFields = fields; return this as any; }
where(condition: string): this { this.conditions.push(condition); return this; }
orderBy(field: keyof T): this { this.orderByField = field; return this; }
build(): string { const select = this.selectFields.length > 0 ? this.selectFields.join(', ') : '*';
let query = `SELECT ${select} FROM table`;
if (this.conditions.length > 0) { query += ` WHERE ${this.conditions.join(' AND ')}`; }
if (this.orderByField) { query += ` ORDER BY ${String(this.orderByField)}`; }
return query; }}
interface User { id: number; name: string; email: string; age: number;}
const query = new QueryBuilder<User>() .select('name', 'email') // Type-safe field selection .where('age > 18') .orderBy('name') // Type-safe ordering .build();
console.log(query); // SELECT name, email FROM table WHERE age > 18 ORDER BY nameVariance in TypeScript
Understanding covariance and contravariance is crucial for advanced generic usage:
Covariance
Types are covariant when they preserve the ordering of their type arguments:
interface Producer<out T> { produce(): T;}
// Covariant - Producer<Dog> is assignable to Producer<Animal>class Animal { name: string = "";}
class Dog extends Animal { breed: string = "";}
declare const dogProducer: Producer<Dog>;const animalProducer: Producer<Animal> = dogProducer; // ✅ OKContravariance
Types are contravariant when they reverse the ordering:
interface Consumer<in T> { consume(item: T): void;}
// Contravariant - Consumer<Animal> is assignable to Consumer<Dog>declare const animalConsumer: Consumer<Animal>;const dogConsumer: Consumer<Dog> = animalConsumer; // ✅ OKBivariance and Invariance
interface Transformer<T> { transform(input: T): T;}
// Invariant - exact type match requireddeclare const dogTransformer: Transformer<Dog>;// const animalTransformer: Transformer<Animal> = dogTransformer; // ❌ ErrorAdvanced Constraint Patterns
Conditional Constraints
type ApiResponse<T> = T extends string ? { message: T } : T extends number ? { count: T } : { data: T };
function handleResponse<T>(response: ApiResponse<T>): void { // TypeScript knows the shape based on T}
// UsagehandleResponse({ message: "Success" }); // T inferred as stringhandleResponse({ count: 42 }); // T inferred as numberhandleResponse({ data: { id: 1, name: "John" } }); // T inferred as objectRecursive Constraints
type DeepPartial<T> = { [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];};
type DeepRequired<T> = { [P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P];};
interface Config { database: { host: string; port: number; credentials: { username: string; password: string; }; }; cache: { enabled: boolean; ttl: number; };}
type PartialConfig = DeepPartial<Config>;// All properties and nested properties are optional
type RequiredConfig = DeepRequired<PartialConfig>;// All properties and nested properties are required againGeneric Utility Creation
Creating Reusable Utilities
// Extract function parameterstype Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
// Create a curried version of a functiontype Curry<T> = T extends (arg: infer A, ...rest: infer R) => infer Return ? R extends [] ? (arg: A) => Return : (arg: A) => Curry<(...args: R) => Return> : never;
// Example usagefunction add(a: number, b: number, c: number): number { return a + b + c;}
type CurriedAdd = Curry<typeof add>;// Type: (arg: number) => (arg: number) => (arg: number) => number
// Implement curry functionfunction curry<T extends (...args: any[]) => any>(fn: T): Curry<T> { return function curried(...args: any[]): any { if (args.length >= fn.length) { return fn.apply(this, args); } else { return function (...args2: any[]) { return curried.apply(this, args.concat(args2)); }; } } as Curry<T>;}
const curriedAdd = curry(add);const result = curriedAdd(1)(2)(3); // 6Performance Considerations
Avoiding Deep Recursion
// ❌ Can cause performance issues with deep objectstype BadDeepReadonly<T> = { readonly [P in keyof T]: T[P] extends object ? BadDeepReadonly<T[P]> : T[P];};
// ✅ Better approach with depth limittype DeepReadonly<T, Depth extends number = 5> = Depth extends 0 ? T : { readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P], Prev<Depth>> : T[P]; };
type Prev<T extends number> = T extends 5 ? 4 : T extends 4 ? 3 : T extends 3 ? 2 : T extends 2 ? 1 : T extends 1 ? 0 : never;Lazy Evaluation
// Use conditional types for lazy evaluationtype LazyPick<T, K> = K extends keyof T ? Pick<T, K> : never;
// This is more efficient than eager evaluationtype EfficientPartial<T> = { [P in keyof T]?: T[P];};Best Practices
1. Use Meaningful Generic Names
// ❌ Poor namingfunction process<T, U, V>(input: T, mapper: (item: T) => U, filter: (item: U) => V): V[] { // implementation}
// ✅ Clear namingfunction processItems<TInput, TMapped, TFiltered>( input: TInput, mapper: (item: TInput) => TMapped, filter: (item: TMapped) => TFiltered): TFiltered[] { // implementation}2. Provide Default Generic Parameters
interface ApiClient<TResponse = any, TError = Error> { get<T = TResponse>(url: string): Promise<T>; post<T = TResponse>(url: string, data: any): Promise<T>;}
// Usage with defaultsconst client: ApiClient = new ApiClientImpl();
// Usage with specific typesconst typedClient: ApiClient<User, ApiError> = new ApiClientImpl();3. Use Generic Constraints Wisely
// ✅ Good constraint usageinterface Repository<T extends { id: string }> { findById(id: string): Promise<T>; save(entity: T): Promise<T>; delete(id: string): Promise<void>;}
// ✅ This ensures all entities have an id fieldclass UserRepository implements Repository<User> { // Implementation guaranteed to work with User type}Common Pitfalls
1. Over-constraining Generics
// ❌ Too restrictivefunction processArray<T extends string[]>(arr: T): T { return arr.map(item => item.toUpperCase()) as T;}
// ✅ More flexiblefunction processArray<T extends string>(arr: T[]): T[] { return arr.map(item => item.toUpperCase() as T);}2. Forgetting About Type Inference
// ❌ Explicit types when inference worksconst result = processData<string>("hello");
// ✅ Let TypeScript inferconst result = processData("hello"); // T inferred as stringConclusion
Advanced generics and constraints are essential for building robust, reusable TypeScript code. Key takeaways:
- Use constraints to limit and guide generic types
- Leverage conditional types for type-level logic
- Understand variance for proper type relationships
- Create reusable generic utilities
- Consider performance implications
- Follow naming and design best practices
Mastering these patterns will enable you to build sophisticated type systems that catch errors at compile time and provide excellent developer experience.
Next Steps
In the next part, we’ll explore Conditional Types & Type Manipulation, diving deeper into TypeScript’s type-level programming capabilities and advanced type manipulation techniques.