TypeScript generics let you write reusable, type-safe code, but without constraints they are too permissive. The extends keyword restricts type parameters to shapes that have specific properties, giving you both flexibility and safety.
The extends Constraint
The foundation of generic constraints is the extends keyword. It ensures a type parameter satisfies a required structure:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
Here K extends keyof T guarantees key is a valid property of obj. Attempting to pass an invalid key triggers a compile-time error. Common patterns include constraining to interfaces (T extends { id: string }), union members, or primitive types.
Conditional Types
Conditional types use extends to check type relationships and select types at compile time:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
This enables type-level functions that transform inputs based on structure. A practical example is unwrapping Promise types:
type Unwrap<T> = T extends Promise<infer R> ? R : T;
type Result = Unwrap<Promise<number>>; // number
Conditional types distribute over unions. IsString<string | number> evaluates to boolean (true | false), applying the condition to each union member independently.
Mapped Types with keyof
Mapped types transform every property of an existing type. Combined with keyof, they enable powerful object transformations:
type Readonly<T> = { readonly [P in keyof T]: T[P] };
type Optional<T> = { [P in keyof T]?: T[P] };
TypeScript provides several built-in mapped types built on this pattern:
| Type | Description | Example Use |
|---|---|---|
Partial<T> | All properties optional | PATCH request body |
Required<T> | All properties required | Form submission |
Readonly<T> | All properties read-only | Immutable config |
Pick<T, K> | Select subset of keys | View model from entity |
Record<K, T> | Dictionary type | Lookup tables |
Since TypeScript 4.1, the as clause in mapped types enables key remapping with template literal types:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
The infer Keyword
The infer keyword within conditional types extracts types from other types. It powers several built-in utility types:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
Practical applications include extracting component prop types, deriving function signatures from third-party libraries, and unwrapping nested generic types. The infer position determines what is extracted — return types, parameter tuples, or constructor arguments.
Factory Functions with Constraints
Generic constraints are essential for factory functions that create objects conforming to specific shapes:
function createInstance<T, U extends T>(
ctor: new (...args: any[]) => T,
data: U
): T {
return Object.assign(new ctor(), data);
}
This pattern is widely used in dependency injection containers, ORM entity factories, and API response transformers. The constraint U extends T ensures the data contains at minimum all properties of T, while allowing additional fields.
Building a Type-Safe API Client
A type-safe API client demonstrates all these concepts working in concert:
type ApiResponse<T> =
| { data: T; error: null }
| { data: null; error: string };
type Endpoints = {
"/users": User;
"/posts": Post;
};
async function fetchApi<E extends keyof Endpoints>(
endpoint: E
): Promise<ApiResponse<Endpoints[E]>> {
const res = await fetch(endpoint);
return res.json();
}
Here, conditional types infer response shapes from endpoint definitions, extends validates route paths, and the lookup type Endpoints[E] retrieves the correct response type.
Performance Considerations
Deeply nested generic types can slow compilation. Mitigate this by avoiding excessively recursive conditional types, using interface merging instead of complex mapped types, and caching computed types with helper aliases. Use tsc --generateTrace to identify bottlenecks.
Mastering extends, conditional types, mapped types, and infer transforms TypeScript into a powerful type programming language. These patterns prevent entire categories of runtime errors and deliver a superior developer experience through compile-time guarantees.
