TypeScript Generics in Practice

TypeScript generics are easy to learn and hard to master. The official docs give you the basics — <T>, constraints with extends, and a few built-in utility types. But real-world TypeScript demands more.

This article covers the patterns I've extracted from maintaining a large TypeScript codebase over several years. These are the patterns that separate "it compiles" from "the type system is working for me."

1. Generic Constraints Beyond the Basics

The standard extends constraint ensures a type parameter has certain properties:

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

But you can constrain to shapes, not just keys:

interface Identifiable {
  id: string;
}

class Repository<T extends Identifiable> {
  private items = new Map<string, T>();

  save(item: T): void {
    this.items.set(item.id, item);
  }

  get(id: string): T | undefined {
    return this.items.get(id);
  }
}

Advanced trick: Constrain to a constructor signature to build factories:

type Constructor<T> = new (...args: any[]) => T;

function createInstance<T>(
  ctor: Constructor<T>,
  ...args: ConstructorParameters<Constructor<T>>
): T {
  return new ctor(...args);
}

2. Conditional Types: If/Else for Types

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

type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false

The real power comes from chaining and the infer keyword:

// Extract element type from an array
type ElementType<T> = T extends (infer U)[] ? U : never;

type C = ElementType<string[]>;  // string

3. The infer Keyword

// Extract the return type of a function
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;

// Extract the type of the first parameter
type FirstParam<T> = T extends (arg: infer P, ...rest: any[]) => any ? P : never;

Real-world use case: Type-safe event emitter

type EventMap = {
  userCreated: { id: string; name: string };
  userDeleted: { id: string };
  error: { message: string; code: number };
};

class TypedEmitter<T extends Record<string, any>> {
  on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void { }
  emit<K extends keyof T>(event: K, payload: T[K]): void { }
}

const emitter = new TypedEmitter<EventMap>();
emitter.on('userCreated', (user) => {
  console.log(user.name);  // type-safe
});

4. Mapped Types

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

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

Key remapping with as (TypeScript 4.1+):

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

type User = { name: string; age: number };
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }

5. Template Literal Types

type Color = 'red' | 'green' | 'blue';
type Brightness = 'light' | 'dark';
type ThemeColor = `${Brightness}-${Color}`;
// 'light-red' | 'light-green' | ... | 'dark-blue'

Production pattern: Spacing props

type Spacing = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
type Direction = 't' | 'r' | 'b' | 'l';
type MarginProp = `m${Direction}${Spacing}`;
type PaddingProp = `p${Direction}${Spacing}`;

6. Branded Types

type Brand<T, B> = T & { __brand: B };

type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;

function getUser(id: UserId): User { /* ... */ }
function getPost(id: PostId): Post { /* ... */ }

const userId = 'abc' as UserId;
getUser(userId);  // OK
getUser(postId);  // Error!

7. Generic Type Guards

function isOfType<T>(
  obj: unknown,
  keys: (keyof T)[]
): obj is T {
  return keys.every((key) => key in obj);
}

interface User { id: string; email: string; }
const data: unknown = JSON.parse(payload);
if (isOfType<User>(data, ['id', 'email'])) {
  console.log(data.email);  // type-safe
}

Summary

Pattern When to Use Key Syntax
Constrained generics Ensuring type params have properties <T extends { id: string }>
Conditional types Type-level branching T extends U ? X : Y
infer Extracting types from other types infer R
Mapped types Transforming object shapes [K in keyof T]: T[K]
Template literals String union manipulation ${A}-${B}
Branded types Distinguishing same-shape types T & { __brand: B }

Generics are not about making code more complex. They're about making code safer by letting the compiler catch mistakes that would otherwise become runtime errors.