On this page

Advanced generics and constraints

14 min read TextCh. 3 — Functions and Generics

Why constraints matter

Generic type parameters accept any type by default. That flexibility is powerful, but it means TypeScript cannot assume the type has any properties or methods. A constraint narrows the set of types a generic parameter accepts, so both you and the compiler know what you can do with it.

// Without constraint — TypeScript does not know T has .length
function logLength<T>(value: T): void {
  console.log(value.length); // Error: Property 'length' does not exist on type 'T'
}

// With constraint — T must have a length property
function logLength<T extends { length: number }>(value: T): void {
  console.log(value.length); // OK
}

logLength("hello");          // 5
logLength([1, 2, 3]);        // 3
logLength({ length: 42 });   // 42

The extends keyword in a generic position does not mean inheritance — it means "is assignable to". T extends { length: number } means "T must be a type that has at least a length: number property."

The `keyof` operator

keyof T produces a union of the string (and number/symbol) literal types that correspond to the keys of T. This is the foundation of many safe utility patterns.

interface Product {
  id: number;
  name: string;
  price: number;
  inStock: boolean;
}

type ProductKeys = keyof Product; // "id" | "name" | "price" | "inStock"

Used with a constraint, keyof unlocks type-safe property access:

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(item => item[key]);
}

const products: Product[] = [
  { id: 1, name: "Keyboard", price: 99, inStock: true },
  { id: 2, name: "Mouse",    price: 49, inStock: false },
];

const names  = pluck(products, "name");   // string[]
const prices = pluck(products, "price");  // number[]

TypeScript infers the correct return type for each call. Passing "name" returns string[]; passing "price" returns number[]. The compiler will reject any string that is not a real key of Product.

Indexed access types

An indexed access type lets you look up a specific property type using bracket notation:

type Price   = Product["price"];       // number
type IdOrName = Product["id" | "name"]; // number | string

// Works with arrays too
type FirstArg = Parameters<typeof pluck>[0]; // T[]

You can also index arrays with number to get the element type:

const palette = ["red", "green", "blue"] as const;
type Color = (typeof palette)[number]; // "red" | "green" | "blue"

Combining constraints with multiple type parameters

Functions often need several generic parameters that relate to each other. Constraints can reference previously declared parameters:

function copyProperty<T extends object, K extends keyof T>(
  source: T,
  target: Partial<T>,
  key: K
): void {
  target[key] = source[key];
}

Here K is constrained to keyof T, so it must be a valid key of whatever object T resolves to. The compiler enforces the relationship automatically.

Conditional types

A conditional type selects one of two types based on a structural check:

type Flatten<T> = T extends Array<infer Item> ? Item : T;

type A = Flatten<string[]>; // string
type B = Flatten<number>;   // number (not an array, passes through)

The syntax mirrors a ternary expression. When the condition holds, TypeScript resolves the type to the left branch; otherwise to the right.

The `infer` keyword

infer introduces a type variable that TypeScript infers from the structure of the matched type. It only works inside the extends branch of a conditional type:

// Extract the resolved type of a Promise
type Awaited<T> = T extends Promise<infer V> ? V : T;

type Resolved = Awaited<Promise<string>>; // string
type Plain    = Awaited<number>;          // number

A practical example: a helper that extracts the first parameter type from any function:

type FirstParam<T extends (...args: unknown[]) => unknown> =
  T extends (first: infer F, ...rest: unknown[]) => unknown ? F : never;

function submit(id: number, payload: string): void {}

type IdType = FirstParam<typeof submit>; // number

Putting it all together

A real-world pattern that uses everything from this lesson — a generic Repository base with type-safe query helpers:

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

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

  pluck<K extends keyof T>(key: K): T[K][] {
    return this.items.map(item => item[key]);
  }

  filter<K extends keyof T>(key: K, value: T[K]): T[] {
    return this.items.filter(item => item[key] === value);
  }
}

interface User extends Entity {
  name: string;
  role: "admin" | "user";
}

const repo = new Repository<User>();
repo.add({ id: 1, name: "Alice", role: "admin" });
repo.add({ id: 2, name: "Bob",   role: "user"  });

const names  = repo.pluck("name");           // string[]
const admins = repo.filter("role", "admin"); // User[]

The pluck and filter methods are fully type-safe: passing an invalid key is a compile-time error, and the return type is always inferred from the actual key you provide.


Practice

  1. Generic constraint: Write a clamp<T extends number>(value: T, min: T, max: T): T function that returns the value clamped between min and max. Verify that passing strings triggers a type error.
  2. keyof + indexed access: Define an interface Config with at least four properties of different types. Write a getConfigValue<K extends keyof Config>(config: Config, key: K): Config[K] function. Call it with different keys and confirm TypeScript infers the correct return type for each.
  3. Conditional types: Create a type UnwrapArray<T> that returns the element type if T is an array, or T itself if it is not. Test it with string[], number[][], and boolean.
  4. infer keyword: Write a FirstElement<T> conditional type that uses infer to extract the first element type of a tuple. Test it with [string, number, boolean] — the result should be string.

Next lesson: narrowing and type guards — techniques to work safely with union types at runtime.

Prefer keyof T over string keys
When you write a function that accepts an object and a property name, always use `K extends keyof T` instead of `key: string`. This way TypeScript knows the return type precisely and catches typos at compile time.
The infer keyword only works inside conditional types
`infer R` declares a type variable R that TypeScript will figure out by pattern-matching. You cannot use `infer` outside a `T extends ... ? ... : ...` expression.
Distributive conditionals can surprise you
When the checked type is a naked type parameter, TypeScript automatically distributes over union members. Write `T extends unknown ? T[] : never` and pass `string | number` — you get `string[] | number[]`, not `(string | number)[]`. Wrap the parameter in a tuple `[T] extends [unknown]` to prevent distribution when needed.
typescript
// Generic constraint with extends
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Alice", email: "[email protected]" };

const name = getProperty(user, "name");   // string
const id   = getProperty(user, "id");     // number
// getProperty(user, "phone");             // Error: not a valid key

// Indexed access types
type UserName = typeof user["name"];       // string
type UserId   = typeof user["id"];         // number

// Generic function with multiple constraints
function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

const profile = merge({ name: "Alice" }, { role: "admin" });
// profile: { name: string } & { role: string }
console.log(profile.name, profile.role);
typescript
// Conditional type — basic form
type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;  // "yes"
type B = IsString<number>;  // "no"

// Conditional types with generics: extract element type from array
type ElementOf<T> = T extends (infer U)[] ? U : never;

type Nums   = ElementOf<number[]>;   // number
type Strs   = ElementOf<string[]>;   // string
type NotArr = ElementOf<boolean>;    // never

// infer in function return types
type ReturnOf<T> = T extends (...args: unknown[]) => infer R ? R : never;

function greet(name: string): string {
  return `Hello, ${name}!`;
}

type GreetReturn = ReturnOf<typeof greet>;  // string

// Distribute over unions
type ToArray<T> = T extends unknown ? T[] : never;
type StrOrNumArrays = ToArray<string | number>;
// string[] | number[]  (distributed!)