Skip to content

TypeScript Generics: From Confusion to Confidence

A practical guide to TypeScript generics that takes you from basic type parameters to advanced conditional types and utility patterns.

4 min read
TypeScript code editor showing generic type definitions with autocomplete

Generics are the point where most developers' TypeScript knowledge hits a wall. The basic concept is simple — "a function that works with any type" — but real-world usage quickly escalates into angle bracket soup that reads like a foreign language. The frustration is valid. Generic syntax is dense. But the alternative — duplicating code for every type or falling back to any — is worse.

The key to understanding generics is recognizing them as functions that operate on types instead of values. Once that mental model clicks, even complex generics become readable.

The Basics: Type Parameters

A generic is a type variable. It lets you write code that works with different types while preserving type information through the operation.

tstypescript
// ❌ Using 'any' — loses type information
function first(arr: any[]): any {
  return arr[0];
}
const x = first([1, 2, 3]); // x: any — useless
 
// ✅ Generic — preserves the input type
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const x = first([1, 2, 3]);     // x: number | undefined
const y = first(["a", "b"]);    // y: string | undefined

TypeScript infers T from the argument — you rarely need to specify it explicitly. The type flows through the function, maintaining the contract between input and output.

Constraining Generics

Unconstrained generics accept any type, which limits what you can do with them inside the function. Constraints (extends) narrow the type while keeping it generic.

tstypescript
// ❌ No constraint — can't access .length
function longest<T>(a: T, b: T): T {
  return a.length > b.length ? a : b; // Error: T has no .length
}
 
// ✅ Constrained — T must have a length property
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length > b.length ? a : b;
}
 
longest("hello", "hi");     // OK — strings have .length
longest([1, 2], [1, 2, 3]); // OK — arrays have .length
longest(10, 20);             // Error — numbers don't have .length

Constraints preserve the specific type while guaranteeing minimum capabilities:

tstypescript
// Extract a property from an object — safely
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const user = { name: "Wilfredo", age: 30, admin: true };
const name = getProperty(user, "name");   // string
const age = getProperty(user, "age");     // number
const bad = getProperty(user, "email");   // Error: "email" not in keyof user

The keyof T constraint means K can only be a valid key of T. The return type T[K] is the type of that specific property — not a union of all property types.

Generic Interfaces and Classes

Generics in interfaces define contracts that callers fill in with specific types.

tstypescript
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(filter: Partial<T>): Promise<T[]>;
  create(data: Omit<T, 'id' | 'createdAt'>): Promise<T>;
  update(id: string, data: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
}
 
interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}
 
class UserRepository implements Repository<User> {
  async findById(id: string): Promise<User | null> {
    return db.query('SELECT * FROM users WHERE id = $1', [id]);
  }
 
  async create(data: Omit<User, 'id' | 'createdAt'>): Promise<User> {
    // data has type: { name: string; email: string }
    return db.query(
      'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
      [data.name, data.email]
    );
  }
 
  // ... implement remaining methods
}

Every Repository<T> implementation gets type-safe access to the entity's structure. Adding a field to User automatically updates the create method's parameter type.

Utility Types: Built-in Generics

TypeScript ships with utility types that are themselves generic patterns worth understanding.

tstypescript
// Partial<T> — all properties optional
type UserUpdate = Partial<User>;
// { id?: string; name?: string; email?: string; createdAt?: Date }
 
// Required<T> — all properties required
type StrictConfig = Required<Config>;
 
// Pick<T, K> — select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: string; name: string }
 
// Omit<T, K> — remove specific properties
type CreateUser = Omit<User, 'id' | 'createdAt'>;
// { name: string; email: string }
 
// Record<K, V> — object with keys of type K and values of type V
type UserMap = Record<string, User>;

These compose naturally:

tstypescript
// API response wrapper — reusable across all endpoints
interface ApiResponse<T> {
  data: T;
  meta: {
    timestamp: string;
    requestId: string;
  };
}
 
type UserResponse = ApiResponse<User>;
type UserListResponse = ApiResponse<User[]>;
type PaginatedResponse<T> = ApiResponse<T[]> & {
  pagination: { page: number; total: number; hasMore: boolean };
};

Conditional Types

Conditional types choose between two types based on a condition. They follow the ternary pattern: T extends U ? X : Y.

tstypescript
// Extract the element type from an array, or keep as-is
type Unwrap<T> = T extends Array<infer U> ? U : T;
 
type A = Unwrap<string[]>;    // string
type B = Unwrap<number[][]>;  // number[]
type C = Unwrap<boolean>;     // boolean
 
// Exclude null/undefined from a type
type NonNullable<T> = T extends null | undefined ? never : T;
 
type D = NonNullable<string | null>;  // string

The infer keyword introduces a type variable inside the condition — it "captures" part of the type being tested:

tstypescript
// Extract the return type of a function
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
 
type E = ReturnOf<() => string>;           // string
type F = ReturnOf<(x: number) => boolean>; // boolean
 
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T;
 
type G = Awaited<Promise<string>>;           // string
type H = Awaited<Promise<Promise<number>>>;  // number (recursive!)

Practical Generic Patterns

Type-Safe Event Emitter

tstypescript
type EventMap = {
  'user:created': { id: string; name: string };
  'user:deleted': { id: string };
  'order:placed': { orderId: string; total: number };
};
 
class TypedEmitter<T extends Record<string, unknown>> {
  private listeners = new Map<keyof T, Set<Function>>();
 
  on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(handler);
  }
 
  emit<K extends keyof T>(event: K, payload: T[K]): void {
    this.listeners.get(event)?.forEach((handler) => handler(payload));
  }
}
 
const emitter = new TypedEmitter<EventMap>();
emitter.on('user:created', (data) => {
  // data is { id: string; name: string } — fully typed
  console.log(data.name);
});
emitter.emit('user:created', { id: '1', name: 'Wilfredo' }); // ✅
emitter.emit('user:created', { id: '1' }); // ❌ Error: missing 'name'

Builder Pattern with Generics

tstypescript
type BuilderState<T> = {
  [K in keyof T]?: T[K];
};
 
class QueryBuilder<T> {
  private conditions: string[] = [];
  private params: unknown[] = [];
 
  where<K extends keyof T & string>(
    field: K,
    op: '=' | '>' | '<' | 'LIKE',
    value: T[K]
  ): this {
    this.params.push(value);
    this.conditions.push(`${field} ${op} $${this.params.length}`);
    return this;
  }
 
  build(): { sql: string; params: unknown[] } {
    const where = this.conditions.length
      ? `WHERE ${this.conditions.join(' AND ')}`
      : '';
    return { sql: where, params: this.params };
  }
}
 
const query = new QueryBuilder<User>()
  .where('name', 'LIKE', '%Wilfredo%')
  .where('age', '>', 25) // ❌ Error if age is string in User
  .build();

Key Takeaways

  1. Generics are type-level functions — they transform types the way regular functions transform values
  2. Constraints (extends) are your safety net — they guarantee minimum capabilities without losing specificity
  3. Let TypeScript infer — explicit type arguments are rarely needed when inference works
  4. Compose utility types — Pick, Omit, Partial, and Record build complex types from simple ones
  5. Conditional types enable type-level logic — use infer to extract types from complex structures
  6. Start simple, add generics when duplication appears — premature abstraction in types is as harmful as in code
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX