Skip to content
Codeloom
TypeScript

TypeScript Generics: Advanced Patterns and Constraints

Master advanced TypeScript generics patterns including conditional constraints, recursive types, generic factories, and type-safe builder patterns with examples.

·8 min read · By Codeloom
Advanced 14 min read

What you'll learn

  • How to use generic constraints to limit type parameters
  • Advanced patterns like generic factories and conditional generics
  • Recursive generics for tree structures and deep operations
  • Real-world patterns for type-safe APIs and builders

Prerequisites

  • Basic TypeScript generics
  • Familiarity with interfaces and type aliases

If you have used Array<T> or Promise<T>, you already know the basics of generics. This article goes beyond the fundamentals into patterns that make generics truly powerful: constraints that enforce relationships between types, recursive types that model nested data, and factory patterns that produce perfectly typed results.

Constraints with extends

The extends keyword in a generic context constrains what types the parameter can accept:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Alice", age: 30, email: "alice@example.com" };

const name = getProperty(user, "name");  // type: string
const age = getProperty(user, "age");    // type: number
// getProperty(user, "phone");           // Error: "phone" is not assignable

K extends keyof T means K must be one of the keys of T. The return type T[K] is an indexed access type that resolves to the type of that specific property.

Constraining to Specific Shapes

You can require that a type parameter has certain properties:

interface HasId {
  id: string | number;
}

function findById<T extends HasId>(items: T[], id: T["id"]): T | undefined {
  return items.find(item => item.id === id);
}

const users = [
  { id: 1, name: "Alice", role: "admin" },
  { id: 2, name: "Bob", role: "user" },
];

const found = findById(users, 1);
// type: { id: number; name: string; role: string } | undefined

The constraint T extends HasId ensures that whatever type T is, it must have an id property. But the return type preserves the full type of T, not just HasId.

Multiple Type Parameters with Relationships

Generics become powerful when you establish relationships between multiple parameters:

function merge<T, U>(target: T, source: U): T & U {
  return { ...target, ...source };
}

const result = merge(
  { name: "Alice" },
  { age: 30, email: "alice@example.com" }
);
// type: { name: string } & { age: number; email: string }

console.log(result.name);  // string
console.log(result.age);   // number
console.log(result.email);  // string

A more constrained example where the second type must extend the first:

function override<T extends object, U extends Partial<T>>(
  base: T,
  overrides: U
): T {
  return { ...base, ...overrides };
}

const defaults = { host: "localhost", port: 3000, debug: false };

const config = override(defaults, { port: 8080 });    // OK
// override(defaults, { invalid: true });              // Error

Generic Factories

Factory functions that return correctly typed instances:

interface Constructor<T> {
  new (...args: any[]): T;
}

function createInstance<T>(ctor: Constructor<T>, ...args: any[]): T {
  return new ctor(...args);
}

class UserService {
  constructor(public apiUrl: string) {}
  
  getUsers() {
    return fetch(this.apiUrl).then(r => r.json());
  }
}

const service = createInstance(UserService, "/api/users");
// type: UserService - full autocomplete works

A more type-safe factory with a registry:

interface ServiceMap {
  auth: AuthService;
  users: UserService;
  payments: PaymentService;
}

class Container {
  #instances = new Map<string, any>();
  #factories = new Map<string, () => any>();

  register<K extends keyof ServiceMap>(
    key: K,
    factory: () => ServiceMap[K]
  ): void {
    this.#factories.set(key, factory);
  }

  resolve<K extends keyof ServiceMap>(key: K): ServiceMap[K] {
    if (!this.#instances.has(key)) {
      const factory = this.#factories.get(key);
      if (!factory) throw new Error(`No factory for ${key}`);
      this.#instances.set(key, factory());
    }
    return this.#instances.get(key);
  }
}

const container = new Container();
container.register("auth", () => new AuthService());
container.register("users", () => new UserService("/api"));

const auth = container.resolve("auth");   // type: AuthService
const users = container.resolve("users"); // type: UserService

Generic Builder Pattern

The builder pattern benefits enormously from generics, especially when you want to track which fields have been set:

type SetFields<T, K extends keyof T> = {
  [P in keyof T]: P extends K ? T[P] : T[P] | undefined;
};

class QueryBuilder<T extends Record<string, any>> {
  #table: string;
  #conditions: string[] = [];
  #selectedFields: string[] = [];
  #orderField: string | null = null;
  #limitCount: number | null = null;

  constructor(table: string) {
    this.#table = table;
  }

  select<K extends keyof T>(...fields: K[]): QueryBuilder<Pick<T, K>> {
    this.#selectedFields = fields as string[];
    return this as any;
  }

  where<K extends keyof T>(
    field: K,
    operator: "=" | "!=" | ">" | "<" | ">=" | "<=",
    value: T[K]
  ): this {
    this.#conditions.push(`${String(field)} ${operator} ${JSON.stringify(value)}`);
    return this;
  }

  orderBy<K extends keyof T>(field: K, direction: "asc" | "desc" = "asc"): this {
    this.#orderField = `${String(field)} ${direction}`;
    return this;
  }

  limit(count: number): this {
    this.#limitCount = count;
    return this;
  }

  build(): string {
    const fields = this.#selectedFields.length > 0
      ? this.#selectedFields.join(", ")
      : "*";
    let query = `SELECT ${fields} FROM ${this.#table}`;
    if (this.#conditions.length > 0) {
      query += ` WHERE ${this.#conditions.join(" AND ")}`;
    }
    if (this.#orderField) {
      query += ` ORDER BY ${this.#orderField}`;
    }
    if (this.#limitCount !== null) {
      query += ` LIMIT ${this.#limitCount}`;
    }
    return query;
  }
}

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
  active: boolean;
}

const query = new QueryBuilder<User>("users")
  .select("name", "email")
  .where("age", ">", 18)
  .where("active", "=", true)
  .orderBy("name")
  .limit(10)
  .build();

The select method narrows the generic type, so subsequent operations only allow fields that were selected.

Recursive Generics

Types that reference themselves are essential for modeling tree structures and nested data:

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  server: {
    port: number;
    cors: boolean;
  };
}

function mergeConfig(base: Config, overrides: DeepPartial<Config>): Config {
  return deepMerge(base, overrides) as Config;
}

const config = mergeConfig(defaultConfig, {
  database: {
    credentials: {
      password: "new-password",
    },
  },
});

A recursive type for deeply nested paths:

type Path<T> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? K | `${K}.${Path<T[K]>}`
        : K;
    }[keyof T & string]
  : never;

type UserPaths = Path<User>;
// "id" | "name" | "email" | "address" | "address.street" | "address.city" | ...

function getByPath<T, P extends Path<T>>(obj: T, path: P): any {
  return path.split(".").reduce((acc: any, key) => acc[key], obj);
}

Generic Conditional Types

Combine generics with conditional types for powerful type-level logic:

type ApiResponse<T> = T extends Array<infer U>
  ? { data: U[]; total: number; page: number }
  : { data: T };

function fetchResource<T>(url: string): Promise<ApiResponse<T>> {
  return fetch(url).then(r => r.json());
}

// When T is User[], response has pagination
const users = await fetchResource<User[]>("/api/users");
users.data;  // User[]
users.total; // number
users.page;  // number

// When T is User, response is simple
const user = await fetchResource<User>("/api/users/1");
user.data;   // User

Extracting and Filtering Types

type ExtractArrayItem<T> = T extends (infer U)[] ? U : T;

type EventMap = {
  click: { x: number; y: number };
  keypress: { key: string; code: number };
  scroll: { scrollY: number };
};

type EventHandler<K extends keyof EventMap> = (event: EventMap[K]) => void;

function on<K extends keyof EventMap>(
  event: K,
  handler: EventHandler<K>
): void {
  // implementation
}

on("click", (event) => {
  console.log(event.x, event.y);  // fully typed
});

on("keypress", (event) => {
  console.log(event.key);  // fully typed
});

Default Type Parameters

Like default function arguments, generic parameters can have defaults:

interface PaginatedResult<T, M = Record<string, never>> {
  data: T[];
  page: number;
  totalPages: number;
  metadata: M;
}

type UserResult = PaginatedResult<User>;
// metadata is Record<string, never>

type SearchResult = PaginatedResult<User, { query: string; took: number }>;
// metadata is { query: string; took: number }

Variance and Generic Interfaces

Understanding covariance and contravariance helps when designing generic interfaces:

interface ReadonlyRepository<out T> {
  findById(id: string): T;
  findAll(): T[];
}

interface WriteRepository<in T> {
  save(entity: T): void;
  delete(entity: T): void;
}

interface Repository<T> extends ReadonlyRepository<T>, WriteRepository<T> {}

The out annotation marks T as covariant (only appears in output positions), and in marks it as contravariant (only appears in input positions). This helps TypeScript verify that your generic interfaces are used safely.

Generic Utility Functions

Common utility patterns that appear in real codebases:

function groupBy<T, K extends string | number>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  const result = {} as Record<K, T[]>;
  for (const item of items) {
    const key = keyFn(item);
    if (!result[key]) result[key] = [];
    result[key].push(item);
  }
  return result;
}

const grouped = groupBy(
  [
    { name: "Alice", dept: "eng" },
    { name: "Bob", dept: "sales" },
    { name: "Carol", dept: "eng" },
  ],
  item => item.dept
);
// type: Record<string, { name: string; dept: string }[]>
function createLookup<T, K extends keyof T>(
  items: T[],
  key: K
): Map<T[K], T> {
  const map = new Map<T[K], T>();
  for (const item of items) {
    map.set(item[key], item);
  }
  return map;
}

const userLookup = createLookup(users, "id");
// type: Map<number, User>

Wrapping Up

Advanced generics let you build APIs where the type system tracks relationships between inputs and outputs automatically. Constraints with extends ensure type parameters meet requirements. Recursive generics model nested structures. Conditional generics change behavior based on the type provided. The key insight is that generics are not about making code work with “any type” but about preserving type information through transformations so that the consumer gets precise types without manual annotations.