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

Further reading

Sources & References

Type parameters, constraints, defaults