Generics and Constraints, Explained

TypeScriptWebType SafetyFundamentals
Share on LinkedIn

The third version of our groupBy utility accepted any[] and returned Record<string, any[]>. It worked until someone grouped by a numeric key, got back "[object Object]" buckets, and spent an afternoon debugging. The fix was a one-line constraint: T extends Record<string, unknown>. Generics with constraints give you the flexibility of polymorphism and the safety of knowing what shape you're working with. They're the difference between a utility that works in demos and one that survives a codebase.

Generics: the basics

A generic function preserves the type relationship between input and output:

function first<T>(items: T[]): T | undefined {
  return items[0];
}

const n = first([1, 2, 3]);     // number | undefined
const s = first(["a", "b"]);      // string | undefined

Without <T>, you'd write first(items: any[]): any and lose everything.

Constraints with extends

When the function needs to access properties on T:

interface Identifiable {
  id: string;
}

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

T extends Identifiable means T can be User, Product, or Order — anything with an id: string — and the return type is the specific type passed in, not just Identifiable.

Multiple constraints

Intersect constraint types for combined requirements:

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

Both must be objects (not primitives). The return type is the intersection.

The keyof constraint pattern

One of the most useful patterns in TypeScript:

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

const user = { name: "Alice", age: 30 };
const name = getProperty(user, "name");  // string
const age  = getProperty(user, "age");   // number
getProperty(user, "email");              // compile error

K extends keyof T ensures the key argument is a valid key of T, and the return type T[K] is the exact property type. This is how Object.getOwnProperty should have been typed.

Extending with keyof for pick/pluck

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

const names = pluck(users, "name");  // string[]

Constraining to literal types

Require a type parameter to be a string literal union:

function createState<S extends string>(
  initial: S
): { value: S; setValue: (v: S) => void } {
  let value = initial;
  return {
    value,
    setValue(v: S) { value = v; },
  };
}

const status = createState("idle" as const);
status.setValue("loading");  // OK
status.setValue("invalid");  // compile error

Generic inference: let TypeScript figure it out

TypeScript infers type arguments from function arguments when possible:

function pair<A, B>(a: A, b: B): [A, B] {
  return [a, b];
}

const p = pair("hello", 42);  // [string, number] — inferred

You can also infer from return position with satisfies or explicit annotation:

const result = pair<string, number>("hello", 42);

Inferring from array elements

function asTuple<T extends readonly [unknown, ...unknown[]]>(arr: T): T {
  return arr;
}

const t = asTuple(["a", 1, true] as const);
// readonly ["a", 1, true]

Building a type-safe event bus

Combining generics and constraints for a real utility:

interface EventMap {
  "user:created": { id: string; name: string };
  "user:deleted": { id: string };
  "order:placed": { orderId: string; total: number };
}

class TypedEventBus<Events extends Record<string, unknown>> {
  private listeners = new Map<string, Set<Function>>();

  on<K extends keyof Events>(
    event: K,
    handler: (payload: Events[K]) => void
  ): () => void {
    const set = this.listeners.get(event as string) ?? new Set();
    set.add(handler);
    this.listeners.set(event as string, set);
    return () => set.delete(handler);
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    const set = this.listeners.get(event as string);
    set?.forEach(handler => handler(payload));
  }
}

const bus = new TypedEventBus<EventMap>();

bus.on("user:created", (payload) => {
  console.log(payload.name);  // typed as { id: string; name: string }
});

bus.emit("user:created", { id: "1", name: "Alice" });  // OK
bus.emit("user:created", { id: "1" });                  // Error: missing name

K extends keyof Events constrains event names to the map's keys. Events[K] resolves the payload type for each key. Add a new event to the map and the compiler enforces it everywhere.

Common constraint patterns

Pattern Constraint Use case
T extends string T is a string or literal String manipulation utilities
T extends keyof U T is a key of U Property access, pick, pluck
T extends unknown[] T is an array Array utilities
T extends (...args: any[]) => any T is a function Function wrappers, decorators
T extends Record<string, unknown> T is an object Object manipulation

Defaults for generic parameters

Provide fallback types when inference isn't possible:

type ApiResult<T = unknown, E = string> =
  | { data: T; error: never }
  | { data: never; error: E };

type StringResult = ApiResult<string>;
type DefaultResult = ApiResult;  // ApiResult<unknown, string>

Defaults reduce annotation noise at call sites while keeping the generic flexible for cases that need specificity.

Common production mistakes

Teams get generics constraints wrong in predictable ways:

TypeScript patterns for generics constraints erode when any escapes during deadlines, generic constraints are loosened instead of modeling domain invariants, and strict mode is disabled file-by-file without a migration plan.

Resources

Frequently asked questions

What is a generic constraint in TypeScript?

A generic constraint limits what types can be passed as a type argument by requiring the type to extend a specific shape. The syntax T extends SomeType means T must be assignable to SomeType. This lets you access properties on T inside the function body — for example, T extends { id: string } lets you read item.id — while still preserving the specific type the caller passes in.

What is the difference between generics and the any type?

any disables type checking entirely — you lose safety and IDE support. Generics preserve the relationship between input and output types while remaining flexible. A function identity<T>(value: T): T with any becomes identity(value: any): any, which tells the compiler nothing. With generics, passing a string returns a string, passing a number returns a number, and misuse is caught at compile time.

When should I add a constraint versus leaving the generic unbounded?

Leave generics unbounded when the function truly works with any type — identity, array wrapping, Promise creation. Add a constraint when the function accesses specific properties or methods on T — sorting requires T extends Comparable, grouping requires T extends Record<string, unknown>. If you find yourself casting inside a generic function, you probably need a constraint.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →