Why Generics Break People

Generics are TypeScript's most powerful feature and its most abandoned one. Engineers reach for any when the type annotation gets complex, or they over-constrain with T extends objectand wonder why nothing works. The problem isn't complexity โ€” it's that most people learn generics from a container analogy (Array<T>, Promise<T>) and never see the patterns that make them genuinely useful: constraints that unlock methods, inference that eliminates verbosity, and variance that explains why Array<Dog> is not assignable to Array<Animal>.

๐Ÿค” Sound familiar?
  • You write function identity<T>(x: T): Tbut can't explain when to constrain T
  • You've used T extends object instead of T extends Record<string, unknown> and hit unexpected behaviour
  • You copy-paste generic utility types without understanding how infer works
  • TypeScript infers string when you wanted a specific literal like "success"

Three patterns cover 90% of real-world generics: constraints that unlock methods, inference at call sites, and default type parameters for optional flexibility.

Constraints: Unlock Methods Without Losing Flexibility

A type parameter with no constraint is maximally flexible โ€” but that means TypeScript assumes it could be null, undefined, a number, anything. The moment you need to call a method on it, you need a constraint.

// โŒ Error: T might not have .length
function firstChars<T>(value: T, count: number): T {
  return value.slice(0, count); // Property 'slice' does not exist on type 'T'
}

// โœ… Constrain to what you need
function firstChars<T extends { length: number; slice(start: number, end: number): T }>(
  value: T,
  count: number
): T {
  return value.slice(0, count);
}

// Works for string AND array โ€” not just one type
firstChars('hello world', 5);   // 'hello'
firstChars([1, 2, 3, 4, 5], 3); // [1, 2, 3]

The key insight: constrain to the shape you need, not the type you expect. This is structural typing in action โ€” any type with those properties satisfies the constraint.


flowchart TD
    T["T (unconstrained)"] --> any["Can be: string | number | null | object | ..."]
    TC["T extends HasLength"] --> str["string โœ“"]
    TC --> arr["T[] โœ“"]
    TC --> buf["Buffer โœ“"]
    TC --> nope["number โœ— โ€” no .length"]

    style TC fill:#0078D4,color:#fff,stroke:#005a9e
    style T fill:#6b7280,color:#fff,stroke:#4b5563

Inference: Let TypeScript Do the Work

TypeScript infers type parameters from the arguments you pass. Understanding where inference happens โ€” and where it doesn't โ€” prevents most generic frustration.

// TypeScript infers T from the argument
function wrap<T>(value: T): { value: T } {
  return { value };
}

const a = wrap('hello'); // { value: string }  โ† T inferred as string
const b = wrap(42);      // { value: number }  โ† T inferred as number

// For literal inference, use 'const' type parameter (TS 5.0+)
function wrapLiteral<const T>(value: T): { value: T } {
  return { value };
}

const c = wrapLiteral('hello'); // { value: "hello" }  โ† literal, not widened

// The problem: inference fails for unused type positions
function createPair<T, U>(): [T, U] {
  // TypeScript can't infer T and U โ€” they don't appear in the parameters
  return [] as [T, U]; // โ† you must pass them explicitly
}
const pair = createPair<string, number>(); // fine, explicit

Inference Deferral with infer

The infer keyword captures a type from within a conditional type. It's the basis for ReturnType, Parameters, Awaited, and dozens of utility types you use daily.

// Built from scratch: extract the return type of any function
type MyReturnType<T extends (...args: any[]) => any> =
  T extends (...args: any[]) => infer R ? R : never;

// Usage
type R1 = MyReturnType<() => string>;          // string
type R2 = MyReturnType<(x: number) => boolean>; // boolean
type R3 = MyReturnType<typeof fetch>;           // Promise<Response>

// Extract the element type from an array
type ElementOf<T extends readonly unknown[]> =
  T extends readonly (infer E)[] ? E : never;

type E1 = ElementOf<string[]>;    // string
type E2 = ElementOf<[1, 'a', true]>; // 1 | "a" | true

Default Type Parameters

Default types work exactly like default function parameters. They make optional generics opt-in without requiring callers to spell them out.

interface ApiResponse<T = unknown, E = string> {
  data: T;
  error: E | null;
  status: number;
}

// Full explicit usage
const typed: ApiResponse<User, ApiError> = { data: user, error: null, status: 200 };

// Partial โ€” uses default E = string
const partial: ApiResponse<User> = { data: user, error: 'Not found', status: 404 };

// All defaults โ€” least specific, for unknown responses
const raw: ApiResponse = { data: responseBody, error: null, status: 200 };

Variance: Why Array<Dog> Is Not Array<Animal>

Variance explains whether a generic type is assignable to another with a related type argument. This is where TypeScript surprises most people.

class Animal { name = ''; }
class Dog extends Animal { bark() {} }

// Covariance: read-only container โ€” safe to widen
type ReadOnly<T> = { readonly value: T };
const dogs: ReadOnly<Dog> = { value: new Dog() };
const animals: ReadOnly<Animal> = dogs; // โœ… OK โ€” we only read, no mutation risk

// Invariance: mutable container โ€” NOT safe
type Mutable<T> = { value: T };
const mutableDogs: Mutable<Dog> = { value: new Dog() };
// const mutableAnimals: Mutable<Animal> = mutableDogs; // โŒ Error
// Why? If allowed: mutableAnimals.value = new Cat(); โ† now mutableDogs.value is a Cat!

// The practical version โ€” Array<Dog> does NOT extend Array<Animal>
const dogArray: Array<Dog> = [new Dog()];
// const animalArray: Array<Animal> = dogArray; // โŒ Error in strict mode

Generic Classes and Interfaces

// Generic repository โ€” works for any entity with an id
interface Repository<T extends { id: string }> {
  findById(id: string): Promise<T | null>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

class InMemoryRepository<T extends { id: string }> implements Repository<T> {
  private store = new Map<string, T>();

  async findById(id: string): Promise<T | null> {
    return this.store.get(id) ?? null;
  }

  async save(entity: T): Promise<T> {
    this.store.set(entity.id, entity);
    return entity;
  }

  async delete(id: string): Promise<void> {
    this.store.delete(id);
  }
}

// Now specialise without rewriting
const userRepo = new InMemoryRepository<User>();
const postRepo = new InMemoryRepository<Post>();

Common Pitfalls

Pitfall 1: Over-constraining with T extends object

// T extends object is almost never what you want
// object excludes primitives but includes functions, arrays, null (in JS)
// Use T extends Record<string, unknown> for plain objects
// Use T extends NonNullable<unknown> if you just want non-null

function getKeys<T extends Record<string, unknown>>(obj: T): (keyof T)[] {
  return Object.keys(obj) as (keyof T)[];
}

Pitfall 2: Unnecessary explicit type arguments

// โŒ Redundant โ€” TypeScript infers this
const result = wrap<string>('hello');

// โœ… Let inference do it
const result = wrap('hello');

// The exception: when inference gets it wrong or widens too much
const result = wrapLiteral<'success'>('success'); // force literal if needed

Pitfall 3: Generic functions that should be overloads

// โŒ Generic but different return types based on input type โ€” confusing
function parse<T>(input: string | Buffer): T { ... }

// โœ… Overloads are clearer when the relationship isn't truly generic
function parse(input: string): ParsedString;
function parse(input: Buffer): ParsedBuffer;
function parse(input: string | Buffer): ParsedString | ParsedBuffer { ... }