Skip to content

Generics and Constraints

intermediate10 min read

Functions That Work With Any Type — Safely

Without generics, you face a choice: write a function for every type (repetitive), or use any (unsafe). Generics give you both — a single function that works with any type while preserving exact type information through the call.

Mental Model

Think of generics as fill-in-the-blank types. When you write function identity<T>(value: T): T, the T is a blank. The caller fills in the blank when they call the function: identity<string>("hello") fills T = string. But the genius is that TypeScript can usually fill in the blank automatically from the argument: identity("hello") — TypeScript sees a string argument and infers T = string. The blank gets filled, and the return type is string, not any.

Generic Functions

// Without generics — loses type information
function firstElement(arr: any[]): any {
  return arr[0];
}
const x = firstElement([1, 2, 3]); // x: any — type information lost

// With generics — preserves type information
function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}
const x = firstElement([1, 2, 3]);     // x: number | undefined
const y = firstElement(["a", "b"]);    // y: string | undefined
const z = firstElement<boolean>([]);    // z: boolean | undefined (explicit)

Multiple type parameters:

function zip<A, B>(a: A[], b: B[]): [A, B][] {
  const result: [A, B][] = [];
  for (let i = 0; i < Math.min(a.length, b.length); i++) {
    result.push([a[i], b[i]]);
  }
  return result;
}

const pairs = zip([1, 2, 3], ["a", "b", "c"]);
// pairs: [number, string][]

Constraints with extends

Unconstrained generics accept anything. Constraints limit T to types that match a specific shape:

// Unconstrained — T could be anything, can't access .length
function logLength<T>(value: T) {
  console.log(value.length); // ERROR: Property 'length' does not exist on type 'T'
}

// Constrained — T must have a length property
function logLength<T extends { length: number }>(value: T) {
  console.log(value.length); // OK — T is guaranteed to have length
}

logLength("hello");        // OK — string has length
logLength([1, 2, 3]);     // OK — array has length
logLength({ length: 10 }); // OK — has length property
logLength(42);             // ERROR — number doesn't have length

keyof Constraint — Type-Safe Property Access

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" };

getProperty(user, "name");  // Returns string
getProperty(user, "age");   // Returns number
getProperty(user, "foo");   // ERROR: Argument '"foo"' is not assignable to '"name" | "age" | "email"'
How T[K] (indexed access types) work

T[K] is an indexed access type — it looks up the type of property K on type T, just like obj[key] looks up the value at runtime. When T is { name: string; age: number } and K is "name", then T[K] resolves to string.

When K is a union like "name" | "age", T[K] distributes and becomes string | number. This is why keyof T gives you the union of all property name types, and T[keyof T] gives you the union of all property value types.

type User = { name: string; age: number; active: boolean };
type Keys = keyof User;           // "name" | "age" | "active"
type Values = User[keyof User];   // string | number | boolean
type NameType = User["name"];     // string

Generic Interfaces and Types

// Generic interface
interface ApiResponse<T> {
  data: T;
  status: number;
  timestamp: Date;
}

const userResponse: ApiResponse<User> = {
  data: { name: "Alice", age: 30 },
  status: 200,
  timestamp: new Date(),
};

// Generic type alias
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

// Generic with multiple constraints
interface Repository<T extends { id: string }> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

Default Type Parameters

Like default function parameters, type parameters can have defaults:

// Default type parameter
type Container<T = string> = {
  value: T;
  metadata: Record<string, unknown>;
};

const stringContainer: Container = { value: "hello", metadata: {} }; // T defaults to string
const numberContainer: Container<number> = { value: 42, metadata: {} };

// Practical example: event emitter
type EventMap = Record<string, unknown>;

class Emitter<Events extends EventMap = Record<string, never>> {
  private handlers = new Map<string, Function[]>();

  on<K extends keyof Events>(event: K, handler: (data: Events[K]) => void) {
    const list = this.handlers.get(event as string) ?? [];
    list.push(handler);
    this.handlers.set(event as string, list);
  }

  emit<K extends keyof Events>(event: K, data: Events[K]) {
    this.handlers.get(event as string)?.forEach(fn => fn(data));
  }
}

// Usage with custom events
interface AppEvents {
  userLogin: { userId: string };
  pageView: { path: string };
  error: { message: string; code: number };
}

const emitter = new Emitter<AppEvents>();
emitter.on("userLogin", (data) => {
  console.log(data.userId); // data: { userId: string } — fully typed
});
emitter.emit("error", { message: "fail", code: 500 }); // Type-safe
emitter.emit("error", { message: "fail" }); // ERROR: missing 'code'

Production Scenario: Type-Safe Data Fetching Hook

interface UseFetchOptions<T> {
  url: string;
  transform?: (raw: unknown) => T;
  enabled?: boolean;
}

type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function useFetch<T>(options: UseFetchOptions<T>): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ status: "idle" });

  useEffect(() => {
    if (options.enabled === false) return;

    setState({ status: "loading" });

    fetch(options.url)
      .then(res => res.json())
      .then(raw => {
        const data = options.transform ? options.transform(raw) : (raw as T);
        setState({ status: "success", data });
      })
      .catch(error => {
        setState({ status: "error", error });
      });
  }, [options.url, options.enabled]);

  return state;
}

// Caller gets exact types
const userState = useFetch<User>({
  url: "/api/user/1",
  transform: (raw) => userSchema.parse(raw), // Zod validation
});

if (userState.status === "success") {
  userState.data.name; // data: User — fully typed
}
Execution Trace
Call:
`useFetchUser(( url, transform ))`
T is bound to User at the call site
Options:
`UseFetchOptionsUser`
transform becomes `(raw: unknown) => User`
State:
`FetchStateUser`
Success variant has `data: User`
Return:
`FetchStateUser`
Caller narrows with status checks to access typed data
Common Mistakes
  • Wrong: Using generics where a concrete type suffices: function greet<T extends string>(name: T) Right: Use the concrete type directly: function greet(name: string)

  • Wrong: Constraining with T extends any or T extends unknown Right: Omit the constraint — unconstrained T already accepts everything

  • Wrong: Using T extends string | number when only string methods are called Right: Constrain to the minimum: T extends string if you call string methods

  • Wrong: Forgetting that inferred T is the exact type, not the constraint Right: T extends { length: number } but T preserves the full type — string, array, etc.

Quiz
What does T extend to when calling: firstElement([1, 'hello', true])?
Quiz
Why does getProperty(user, 'foo') fail even though T is generic?
Quiz
What happens if you call a function with default type parameter as: Container<number>?

Build a Type-Safe Event Emitter

Key Rules
  1. 1Generics preserve type information through function calls — use them when input types determine output types
  2. 2Constrain generics with extends to the minimum shape needed — T extends { length: number } not T extends any
  3. 3T infers to the exact argument type, not the constraint — the constraint is a minimum, not a ceiling
  4. 4Use default type parameters for commonly-used generic types: type Result<T, E = Error>
  5. 5Don't use generics where a concrete type works — generics add complexity that should be justified by real polymorphism