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 property
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(arg: T): T {
console.log(arg.length);
return arg;
}
logLength("hello"); // ✅ Works - strings have length
logLength([1, 2, 3]); // ✅ Works - arrays have length
logLength({ length: 10, value: 3 }); // ✅ Works - object has length
// logLength(123); // ❌ Error - numbers don't have length

Keyof 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: string
const age = getProperty(person, "age"); // Type: number
// const invalid = getProperty(person, "invalid"); // ❌ Error

Multiple 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 perfectly

Conditional 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>; // true
type Test2 = IsString<number>; // false
type Test3 = IsString<"hello">; // true

Distributive 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 function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type FuncReturn = ReturnType<() => string>; // string
type AsyncReturn = ReturnType<() => Promise<number>>; // Promise<number>
// Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : never;
type StringElement = ArrayElement<string[]>; // string
type NumberElement = ArrayElement<number[]>; // number
// Extract promise value type
type PromiseValue<T> = T extends Promise<infer U> ? U : T;
type AsyncString = PromiseValue<Promise<string>>; // string
type SyncNumber = PromiseValue<number>; // number

Advanced 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: User
const users = userFactory.createMany(5, "Jane", "jane@example.com"); // Type: User[]

Higher-Order Type Functions

Create functions that operate on types:

// Type-level function composition
type 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 operations
type 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 readonly

Generic 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 name

Variance 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; // ✅ OK

Contravariance

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; // ✅ OK

Bivariance and Invariance

interface Transformer<T> {
transform(input: T): T;
}
// Invariant - exact type match required
declare const dogTransformer: Transformer<Dog>;
// const animalTransformer: Transformer<Animal> = dogTransformer; // ❌ Error

Advanced 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
}
// Usage
handleResponse({ message: "Success" }); // T inferred as string
handleResponse({ count: 42 }); // T inferred as number
handleResponse({ data: { id: 1, name: "John" } }); // T inferred as object

Recursive 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 again

Generic Utility Creation

Creating Reusable Utilities

// Extract function parameters
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
// Create a curried version of a function
type 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 usage
function 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 function
function 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); // 6

Performance Considerations

Avoiding Deep Recursion

// ❌ Can cause performance issues with deep objects
type BadDeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? BadDeepReadonly<T[P]> : T[P];
};
// ✅ Better approach with depth limit
type 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 evaluation
type LazyPick<T, K> = K extends keyof T ? Pick<T, K> : never;
// This is more efficient than eager evaluation
type EfficientPartial<T> = {
[P in keyof T]?: T[P];
};

Best Practices

1. Use Meaningful Generic Names

// ❌ Poor naming
function process<T, U, V>(input: T, mapper: (item: T) => U, filter: (item: U) => V): V[] {
// implementation
}
// ✅ Clear naming
function 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 defaults
const client: ApiClient = new ApiClientImpl();
// Usage with specific types
const typedClient: ApiClient<User, ApiError> = new ApiClientImpl();

3. Use Generic Constraints Wisely

// ✅ Good constraint usage
interface 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 field
class UserRepository implements Repository<User> {
// Implementation guaranteed to work with User type
}

Common Pitfalls

1. Over-constraining Generics

// ❌ Too restrictive
function processArray<T extends string[]>(arr: T): T {
return arr.map(item => item.toUpperCase()) as T;
}
// ✅ More flexible
function processArray<T extends string>(arr: T[]): T[] {
return arr.map(item => item.toUpperCase() as T);
}

2. Forgetting About Type Inference

// ❌ Explicit types when inference works
const result = processData<string>("hello");
// ✅ Let TypeScript infer
const result = processData("hello"); // T inferred as string

Conclusion

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.

Share Feedback