Why Generics Break People
An API client had fetchJson<T>(url): Promise<T>, used in seventy places. It looked type-safe. In practice each caller picked whatever T it liked and nothing checked it, so one page treated an array as an order and showed NaN for the total.
Generics are about relationships: the type of what goes in decides the type of what comes out. A type parameter that relates nothing is a cast in disguise. Below is how constraints and inference work, default type parameters, variance (why an array of dogs is not an array of animals), and generic classes and interfaces that stay readable.
- 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>();Controlling Inference With NoInfer
Most problems with generics are inference problems: the compiler picks a wider type than you meant because it considers every argument a candidate. NoInfer<T> (TypeScript 5.4) marks a position that should be checked against the type parameter but not used to infer it.
// Without NoInfer, T is inferred as 'a' | 'b' | 'c' and the call compiles
function pick<T>(options: T[], fallback: NoInfer<T>): T {
return options[0] ?? fallback;
}
pick(['a', 'b'], 'a'); // ok
pick(['a', 'b'], 'c'); // error: 'c' is not assignable to 'a' | 'b'A useful test for any generic signature: can a caller use it without writing angle brackets? If they have to pass type arguments by hand, inference is not doing its job. The usual cause is a type parameter that appears in only one place, which means it is not relating anything and can be replaced by a plain type.
Fixing fetchJson: Make the Type Come From Somewhere
The client in the introduction had T appear once, in the return type. Nothing the caller passes in is connected to it, so TypeScript has nothing to check: fetchJson<Order>('/api/orders') andfetchJson<Order[]>('/api/orders') both compile against the same endpoint. A useful rule from the TypeScript handbook applies: a type parameter should relate at least two things. If it appears only once, it is a cast with better manners.
Give T a second appearance by making the caller pass something that produces it, a parser:
import { z } from 'zod';
async function fetchJson<T>(url: string, parse: (data: unknown) => T): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} from ${url}`);
return parse(await res.json()); // T is checked against real data
}
const OrderSchema = z.object({ id: z.string(), total: z.number() });
const order = await fetchJson('/api/orders/42', OrderSchema.parse);
// ^? { id: string; total: number } inferred from the parser, not assertedNow T is inferred from the parser, the caller never writes it, and the array-versus-object bug from the introduction fails at the boundary with a clear validation error instead of rendering NaN three components later. The general lesson: when a generic function talks to the outside world (network, storage, user input), the type has to come from a runtime check, not from the call site.
Keeping Literal Types With const Type Parameters
Inference widens by default. Pass ['read', 'write'] to a generic function and T becomesstring[], which loses the information you wanted. Callers used to fix this with as const at every call site. Since TypeScript 5.0, the function can ask for the narrow type itself:
function defineRoles<const T extends readonly string[]>(roles: T): T {
return roles;
}
const roles = defineRoles(['read', 'write', 'admin']);
// ^? readonly ['read', 'write', 'admin']
type Role = (typeof roles)[number]; // 'read' | 'write' | 'admin'This is the right tool for configuration helpers, route tables and event-name registries, anywhere the literal values are the point. Add the readonly to the constraint, otherwise mutable array literals will not infer as tuples.
Reading a Generic Signature You Did Not Write
Library types can look hostile: five type parameters, nested conditionals, defaults referring to other parameters. Read them like this. Find where each parameter is introduced and which argument it is inferred from; that tells you what you control. Then hover over a real call site in your editor, because the instantiated type is far easier to read than the declaration. If you still need to know what a result type is, assign it to a variable and hover, or use a satisfies check against the shape you expect. Rarely do you need to understand the whole signature; you need to know which argument drives the type you care about.
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 { ... }
