⚡IntermediateAdvanced Type System

Advanced Generics

Master generic constraints, default type parameters, multiple type variables, and higher-order generic patterns.

Overview

Generics go far beyond simple `<T>` placeholders. At the intermediate level you use **constraints** to restrict what a type parameter can be, **default types** to make generics optional, and **multiple type parameters** that relate to each other. **Constraints** use `extends` to say "T must be at least this shape". `<T extends object>` rules out primitives; `<T extends { id: number }>` requires an id field. TypeScript then lets you access those guaranteed properties inside the generic. **Default type parameters** (`<T = string>`) let callers omit the type argument when the default is fine - exactly like default function parameters. **Bounded generics** combine both: `<T extends Record<string, unknown> = Record<string, unknown>>`. Higher-order generics accept other generics as parameters, enabling patterns like factory functions and builder APIs that remain fully type-safe.

Key Points

  • •Constraint syntax: `<T extends SomeType>` - T must be assignable to SomeType
  • •Access constrained properties inside the function: if `T extends { name: string }`, you can read `value.name`
  • •Default type parameters: `<T = string>` - used when caller omits the type argument
  • •Multiple related parameters: `<K extends keyof T, T>` - K must be a key of T
  • •`keyof` + generics: `function get<T, K extends keyof T>(obj: T, key: K): T[K]` is fully type-safe property access
  • •Generic constraints can reference other type params: `<A, B extends A>` means B must extend A
  • •Generic interfaces and classes: constraints apply at the class/interface level, not just functions

Examples

Generic constraints - requiring shape

// Without constraint, T[key] errors because T could be anything
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: 'Alice', active: true };
const name = getProperty(user, 'name');   // string
const id   = getProperty(user, 'id');     // number
// getProperty(user, 'missing');          // Error: not a key of typeof user

// Constraint requiring a specific shape
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
const found = findById(users, 1); // { id: number; name: string } | undefined

Default type parameters

// Default lets callers omit the type argument
interface ApiResponse<T = unknown> {
  data: T;
  status: number;
  message: string;
}

// Explicit type argument
const userResponse: ApiResponse<{ id: number; name: string }> = {
  data: { id: 1, name: 'Alice' },
  status: 200,
  message: 'OK',
};

// No type argument  -  defaults to unknown
const raw: ApiResponse = {
  data: { anything: true },
  status: 200,
  message: 'OK',
};

// Generic class with default
class Repository<T extends { id: number } = { id: number }> {
  private items: T[] = [];

  add(item: T): void { this.items.push(item); }
  findById(id: number): T | undefined {
    return this.items.find(i => i.id === id);
  }
}

const repo = new Repository<{ id: number; name: string }>();
repo.add({ id: 1, name: 'Alice' });
console.log(repo.findById(1));

Multiple related type parameters

// K must be a key of T  -  the return type T[K] is inferred precisely
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  for (const key of keys) {
    result[key] = obj[key];
  }
  return result;
}

const full = { id: 1, name: 'Alice', email: 'a@a.com', age: 30 };
const partial = pick(full, ['id', 'name']); // { id: number; name: string }

// Factory function  -  generic over the created type
function createPair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}

const pair = createPair('hello', 42);  // [string, number]

// Merge two objects with type safety
function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

const merged = merge({ name: 'Alice' }, { age: 30 });
// { name: string; age: number }
console.log(merged.name, merged.age);

Common Mistakes

  • Using `any` instead of a constraint: `<T extends object>` is far safer than `<T extends any>` - the former rejects primitives
  • Putting the constraint on the wrong parameter: `<K, T extends K>` and `<T, K extends keyof T>` are very different; order matters
  • Forgetting that a constrained T is still generic: inside a function, T is the specific type the caller passed, not the constraint itself

Note

Think of `T extends Constraint` as a minimum requirement contract: "I promise T will have at least these properties." The caller passes a more specific type, TypeScript narrows T to that specific type - not to the constraint floor.

Exercises

Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.

Loading editor…

Completed: 0 / 4 exercises

Knowledge Check

Question 1 of 4

What does `<T extends { id: number }>` mean as a generic constraint?