Skip to content
Codeloom
TypeScript

TypeScript Generics Deep Dive

Go beyond basic generics — constraints, conditional types, mapped types, infer, and real-world patterns for writing type-safe reusable code.

·5 min read · By Codeloom
Advanced 14 min read

What you'll learn

  • How generic type parameters flow through functions, classes, and interfaces
  • Constraining generics with extends and keyof
  • Conditional types, mapped types, and the infer keyword
  • Real-world patterns: builders, factories, and type-safe event emitters

Prerequisites

  • Solid understanding of TypeScript basics (types, interfaces, unions)
  • Familiarity with basic generic syntax ()

Generics let you write code that works with any type while preserving type safety. This guide covers the full spectrum — from constraints to conditional types to real patterns you will use in production.

Generic functions

A generic function declares a type parameter that the caller fills in (or TypeScript infers).

function identity<T>(value: T): T {
  return value;
}

const num = identity(42);        // T is number
const str = identity('hello');   // T is string

TypeScript infers T from the argument. You can also specify it explicitly:

const result = identity<string>('hello');

Multiple type parameters

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

const p = pair('age', 30); // [string, number]

Constraints with extends

Unconstrained generics accept anything. Use extends to narrow the set of allowed types.

function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}

getLength('hello');     // OK — string has .length
getLength([1, 2, 3]);  // OK — array has .length
getLength(42);          // Error — number has no .length

keyof constraints

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');              // Error — 'email' is not in keyof typeof user

The return type T[K] is an indexed access type — it extracts the type of property K from T.

Generic interfaces and classes

interface Repository<T> {
  findById(id: string): Promise<T | null>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

class UserRepo implements Repository<User> {
  async findById(id: string): Promise<User | null> {
    // ...
  }
  async save(user: User): Promise<User> {
    // ...
  }
  async delete(id: string): Promise<void> {
    // ...
  }
}

Generic classes

class Stack<T> {
  private items: T[] = [];

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

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }
}

const numbers = new Stack<number>();
numbers.push(1);
numbers.push(2);
const top = numbers.pop(); // number | undefined

Default type parameters

interface ApiResponse<T = unknown> {
  data: T;
  status: number;
  message: string;
}

const generic: ApiResponse = { data: 'anything', status: 200, message: 'ok' };
const typed: ApiResponse<User[]> = { data: [], status: 200, message: 'ok' };

Conditional types

Conditional types select one of two types based on a condition.

type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false

Distributive conditional types

When T is a union, the condition distributes over each member:

type ToArray<T> = T extends unknown ? T[] : never;

type Result = ToArray<string | number>; // string[] | number[]

The infer keyword

infer lets you extract a type from within a pattern.

type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;

type A = ReturnOf<() => string>;           // string
type B = ReturnOf<(x: number) => boolean>; // boolean

Extract the element type of an array:

type ElementOf<T> = T extends (infer E)[] ? E : never;

type C = ElementOf<string[]>;  // string
type D = ElementOf<number[]>;  // number

Extract promise result:

type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T;

type E = Awaited<Promise<Promise<string>>>; // string

Mapped types

Mapped types iterate over keys to create new types.

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

type Optional<T> = {
  [K in keyof T]?: T[K];
};

Remapping keys

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Person {
  name: string;
  age: number;
}

type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }

Real-world patterns

Type-safe event emitter

type EventMap = {
  login: { userId: string; timestamp: number };
  logout: { userId: string };
  error: { message: string; code: number };
};

class TypedEmitter<T extends Record<string, unknown>> {
  private listeners: Partial<{
    [K in keyof T]: Array<(payload: T[K]) => void>;
  }> = {};

  on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
    const list = this.listeners[event] ?? [];
    list.push(handler);
    this.listeners[event] = list;
  }

  emit<K extends keyof T>(event: K, payload: T[K]): void {
    this.listeners[event]?.forEach(fn => fn(payload));
  }
}

const emitter = new TypedEmitter<EventMap>();
emitter.on('login', ({ userId, timestamp }) => {
  // userId: string, timestamp: number — fully typed
});
emitter.emit('error', { message: 'oops', code: 500 });

Builder pattern

class QueryBuilder<T extends Record<string, unknown>> {
  private conditions: string[] = [];

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

  build(): string {
    return `SELECT * WHERE ${this.conditions.join(' AND ')}`;
  }
}

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

const query = new QueryBuilder<Product>()
  .where('price', '>', 10)
  .where('inStock', '=', true)
  .build();

Summary

Generics are TypeScript’s most powerful feature for writing reusable, type-safe code. Start with simple type parameters, add constraints when you need to narrow what’s accepted, and reach for conditional types and infer when you need to transform or extract types. The patterns compound — once you are comfortable with the building blocks, you can model almost any API shape at the type level.