Featured image of post TypeScript Generic Constraints: Building Type-Safe APIs Featured image of post TypeScript Generic Constraints: Building Type-Safe APIs

TypeScript Generic Constraints: Building Type-Safe APIs

Learn how to build type-safe APIs with TypeScript generic constraints including extends, conditional types, mapped types, and infer patterns.

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:

TypeDescriptionExample Use
Partial<T>All properties optionalPATCH request body
Required<T>All properties requiredForm submission
Readonly<T>All properties read-onlyImmutable config
Pick<T, K>Select subset of keysView model from entity
Record<K, T>Dictionary typeLookup 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.