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>.
- You write
function identity<T>(x: T): Tbut can't explain when to constrain T - You've used
T extends objectinstead ofT extends Record<string, unknown>and hit unexpected behaviour - You copy-paste generic utility types without understanding how
inferworks - TypeScript infers
stringwhen 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, explicitInference 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" | trueDefault 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 modeGeneric 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 neededPitfall 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 { ... }
