Generics
Generics are how you write code that works over many types without giving up type safety — a function or type parameterized by a type it fills in later. They're the feature people find most intimidating and the one that unlocks reusable, precisely-typed abstractions. Once you see a generic as "a type variable," the intimidation fades and the power remains.
You’ve modeled data with unions and narrowed it safely. But some code shouldn’t care about the specific type at all — a function that returns the first element of any array, a container that holds some type. Writing these without generics forces a choice between duplication (one version per type) and giving up safety (using any). Generics dissolve that choice. This post builds them from the intuition up.
The problem generics solve
Consider a function that returns the first element of an array. Without generics you’re stuck:
function first(arr: any[]): any { return arr[0]; }
const n = first([1, 2, 3]); // n is `any` — we lost the type!
Using any works but throws away everything: n should be number, but the compiler no longer knows, so n.toUpperCase() compiles and crashes at runtime. The alternative — writing firstNumber, firstString, firstUser — is absurd duplication. What you want is: “this function works for any type T, and if you pass a T[], you get a T back.” That’s a generic:
function first<T>(arr: T[]): T { return arr[0]; }
const n = first([1, 2, 3]); // T inferred as number → n is number
const s = first(["a", "b"]); // T inferred as string → s is string
<T> declares a type parameter — a variable that stands for a type, filled in when the function is called. The T links the input and output: whatever element type goes in comes out. And you rarely write first<number>(...) explicitly, because TypeScript infers T from the argument. That’s the whole idea: a type variable that connects types together, inferred at the call site.
Generic types and containers
Type parameters work on types, not just functions — this is how you build reusable containers and wrappers:
interface Box<T> { value: T; }
const b: Box<string> = { value: "hi" };
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function parseNumber(s: string): Result<number> {
const n = Number(s);
return isNaN(n) ? { ok: false, error: "not a number" } : { ok: true, value: n };
}
Box<T> is a container holding some type; Result<T> is a discriminated union (post 3) parameterized by the success type — a reusable, type-safe error-handling pattern. You’ve been using generic types all along: Array<T>, Promise<T>, Map<K, V>, Record<K, V> are all generic. Understanding them lets you build your own instead of only consuming the built-ins.
Constraints: generics that require structure
Sometimes a generic can’t be completely arbitrary — the code needs the type to have certain properties. Constraints (extends) restrict a type parameter to types that satisfy a shape:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest([1, 2], [1, 2, 3]); // ✓ arrays have length → returns number[]
longest("ab", "abc"); // ✓ strings have length → returns string
longest(1, 2); // ✗ numbers have no length
T extends { length: number } says “T can be any type, as long as it has a numeric length.” Inside the function you can safely read .length; at the call site only types with a length are allowed. Constraints are the balance point of generics: general enough to be reusable, specific enough to be safe. A common, powerful constraint uses keyof to tie one parameter to another’s keys:
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Ada", age: 36 };
getProp(user, "name"); // returns string (T["name"])
getProp(user, "xyz"); // ✗ "xyz" is not a key of user
K extends keyof T restricts key to the actual keys of the object, and the return type T[K] is the type of that specific property. This is fully type-safe property access — the kind of precision that’s impossible without generics.
Default type parameters
Like function parameters, type parameters can have defaults, used when the caller doesn’t specify and inference can’t determine one:
interface ApiResponse<T = unknown> {
status: number;
data: T;
}
const r1: ApiResponse = { status: 200, data: "anything" }; // T defaults to unknown
const r2: ApiResponse<User> = { status: 200, data: userObject }; // T is User
Defaults make generic types ergonomic: the common case needs no type argument, while callers who want specificity can supply one. unknown is a common default — safe, and forces narrowing before use.
When to use generics — and when not to
Generics are for relationships between types: when the type of the output depends on the type of the input, or when a structure should work uniformly across many types while preserving them. Reach for a generic when you’d otherwise duplicate code per type or fall back to any.
But don’t over-generalize. If a function only ever handles one concrete type, a generic adds noise for no benefit — concrete types are clearer. And deeply nested generics can become unreadable; if a signature needs a diagram to parse, simplify it. The skill is recognizing the relationship generics express — “the output type is tied to the input type” — and using them exactly there. A good generic makes a function work for every type while keeping the exact type information flowing through; a bad one is complexity for its own sake.
Generics are also the foundation for the type-level programming of the next post: utility and mapped types are largely built from generics plus keyof and conditional logic. Get comfortable with type parameters, constraints, and keyof here, and the seemingly-magical utility types become readable.
Key takeaways
- A generic is a type variable (
<T>) — a type parameter filled in later — that lets one function or type work over many types while preserving them, avoiding both per-type duplication and the safety loss ofany. - Type parameters link types together (input
T[]→ outputT) and are usually inferred at the call site, so you rarely write them explicitly. - Generic types build reusable containers and wrappers (
Box<T>, aResult<T>union) — the same mechanism behind the built-inArray<T>,Promise<T>,Map<K,V>. - Constraints (
T extends Shape) restrict a parameter to types with required structure, balancing reusability and safety;K extends keyof Twith returnT[K]gives fully type-safe property access. - Default type parameters (
<T = unknown>) make generic types ergonomic for the common case while allowing specificity — and generics +keyofare the foundation for the utility/mapped types of the next post. - Use generics for relationships between types (output depends on input, uniform-over-many-types); avoid them when a concrete type suffices or when nesting makes signatures unreadable.