TypeScript Generics: Reuse Without Losing Precision

JavaScript developers already write generic code — Array.prototype.map works on arrays of anything. What JavaScript cannot do is describe that generality, so every reusable helper either loses type information (any) or gets copied per type. Generics are TypeScript's solution: type parameters that flow through a function, keeping the compiler's precision while the code stays written once. This lesson builds them from the problem they solve to the production patterns you will meet in real codebases.

Why generics exist

Start with the failure modes of the two obvious alternatives, because every generic you write is a choice against one of them.

The two failure modes: any and duplication

Purpose of the example: show what happens to type safety when a reusable function is written without generics.

// Failure mode 1: the type-erasing version. Written once, but the
// compiler now knows NOTHING about the return value:
function firstAny(items: any[]): any {
  return items[0];
}
const first = firstAny(["a", "b"]);
first.toUpperCase(); // compiles! `any` accepts every member — runtime TypeError

// Failure mode 2: the precise version. Safe, but copied for every type —
// the same three lines with different annotations, forever:
function firstString(items: string[]): string | undefined { return items[0]; }
function firstNumber(items: number[]): number | undefined { return items[0]; }
// ...and for every type you will ever have.

What you should see: the first version compiles but throws TypeError: first.toUpperCase is not a function at runtime — the exact bug class this track exists to eliminate. The second version never throws, but cannot scale.

What we learn: a generic type parameter T is the third option — one implementation whose types are filled in per call site. It is a variable for types, resolved by the checker rather than the runtime.

Your first generic function

Purpose of the example: the same first helper, now written once and precise everywhere it is called.

// T is a type parameter: a placeholder filled in per call.
function first<T>(items: T[]): T | undefined {
  return items[0]; // T | undefined thanks to noUncheckedIndexedAccess
}

const s = first(["a", "b"]);   // T inferred as string  → s: string | undefined
const n = first([1, 2, 3]);    // T inferred as number  → n: number | undefined
s?.toUpperCase();              // OK: the compiler knows s may be a string
// n.toFixed(2);               // ✗ error: n is number | undefined — you MUST
// narrow first, even though the version above never lost that information

What you should see: hovering s in the editor shows string | undefined — written once, precise per call. The union with undefined comes from the strict flag, not from generics; the two compose.

Call site first([1,2,3]) with T as a slot filled by inference from the argument, producing number at the return position
Figure 1 — Inference fills the slot. T is a placeholder in the signature; each call site's argument determines what fills it. You rarely write the angle brackets — you write them exactly when inference is not enough.

Constraints: bounding the unknown

An unconstrained T promises "could be literally anything," which means inside the function you may do nothing with it. Constraints restore usable capabilities while keeping the reuse.

extends: requiring capabilities

Purpose of the example: let the generic body access an id property — but only after promising every caller supplies one.

// The constraint: every T must have AT LEAST an id: string.
// Extra properties are still allowed — T keeps its full precise shape.
type HasId = { id: string };

function byId<T extends HasId>(list: T[], id: string): T | undefined {
  return list.find((item) => item.id === id); // OK: T extends HasId
}

const users = [
  { id: "u1", name: "Ada", admin: true },
  { id: "u2", name: "Grace", admin: false },
];
const ada = byId(users, "u1");       // T inferred as { id, name, admin }
// ada.admin — fully typed, NOT erased to HasId. Constraints bound, they
// do not widen: the return type is the argument's type, not the constraint's.

const bad = byId(["not", "an", "entity"], "x"); // ✗ error: string lacks `id`

What you should see: ada has type { id: string; name: string; admin: boolean } | undefined. This is the property beginners find remarkable: the constraint only adds a requirement; the call-site type survives intact.

What we learn: T extends HasId reads as "any T, as long as it is assignable to HasId" — the same structural rule from the types lesson, applied to the parameter itself.

keyof and indexed access: the safe getter

Purpose of the example: combine a type parameter, keyof, and indexed access to write the property lookup that plain JavaScript cannot make safe.

// K can only be a property name that actually exists on T.
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]; // T[K] — the type of THAT property, not a vague any
}

const config = { host: "localhost", port: 5432, debug: true };

const port = getProp(config, "port");   // type: number
const host = getProp(config, "host");   // type: string
// getProp(config, "hostname");         // ✗ compile error: typo caught HERE

What you should see: the classic JavaScript bug — obj["hostname"] returning undefined silently — is now a compile-time error. The return type T[K] is precise per key: "port" yields number, not "some value".

Inference and when to write the angle brackets

The compiler infers T from arguments, return-position usage, and defaults. Knowing when inference fails tells you when to annotate.

Three situations that need explicit type arguments

Purpose of the example: the practical rule "inference first" with the exact exceptions memorized.

// 1. No argument carries the type — inference has nothing to work with:
const ids: string[] = [];          // annotation carries the type instead
// const oops = [];                // inferred as any[] — the silent trap

// 2. The argument is "too wide" for what you mean:
const status = parseEnum("active", ["active", "banned"] as const);
//                                   ^ literal tuple: T = "active" | "banned"

// 3. The return type depends on a choice the caller must make:
const made = new Map<string, Set<number>>(); // element types not inferable

What you should see: each explicit argument marks a spot where you hold information the compiler cannot infer. If you find yourself writing angle brackets constantly, the signatures you are calling are probably under-designed — good library APIs infer at 95% of call sites.

Production patterns worth memorizing

Two generic shapes appear in virtually every serious TypeScript codebase.

The Result type: errors as values

Purpose of the example: model fallible operations so the compiler forces callers to handle both branches — impossible in plain JavaScript without runtime discipline.

// A discriminated union (see the types lesson) parameterized over the
// success payload AND the error payload — two independent unknowns:
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return { ok: false, error: "division by zero" };
  return { ok: true, value: a / b };
}

const r = divide(10, 0);
if (r.ok) {
  console.log(r.value); // only HERE does r have `.value`
} else {
  console.error(r.error); // and only here `.error` — the check IS the proof
}
// r.value; // ✗ outside the guard: the compiler refuses. No forgotten
// try/catch, no swallowed throw — the failure is IN the return type.

What you should see: the narrowing behavior from the types lesson, now generic. E = Error shows a default type parameter: callers who do not care about custom errors write Result<User>.

Generic pagination across resources

Purpose of the example: the exercise from the original lesson, worked: one shape covering every list endpoint you will ever call.

// The envelope is identical for every resource; only the item type varies:
interface Page<T> {
  items: T[];
  cursor: string | null;   // null = no more pages
  total: number;
}

// One client method serves users, products, orders, logs...:
async function fetchPage<T>(url: string): Promise<Page<T>> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`GET ${url}: ${res.status}`);
  return (await res.json()) as Page<T>; // boundary assertion: see errors lesson
}

type User = { id: string; name: string };
const users = await fetchPage<User>("/api/users");
users.items[0]?.name; // User is threaded through the whole envelope

What you should see: the explicit <User> at the call — situation 3 above, since a URL string reveals nothing about the payload. This one declaration replaces a hand-written interface per endpoint.

Generic smells: when NOT to reach for T

Generics are the most over-quoted feature of the language; three smells cover most misuse.

Smell 1 — used once, concretely

If a function has exactly one caller and one type, a generic adds indirection with zero reuse. Write the concrete type; generalize only when the second real use appears.

Smell 2 — unbounded T touching nothing

function wrap<T>(x: T): T that never uses T's capabilities is a no-op wrapper — either constrain it (so the body can meaningfully interact with the value) or delete it.

Smell 3 — call-signature soup

Multiple nested generics, mapped over conditionals, in one arrow — if the signature needs a paragraph to read, move the logic into named intermediate types (the advanced-types lesson shows how). Readability is a feature of type definitions too.

Practice: generics under your fingers

Build a typed event emitter

Implement class Emitter<Events extends Record<string, unknown[]>> with on<K extends keyof Events>(event: K, handler: (...args: Events[K]) => void). What you should see: subscribing to "login" with a handler expecting the wrong argument types becomes a compile error — the pattern behind every typed event API in React and Node libraries.

Predict the inference

For const x = first([1, "mixed", true]);, write the inferred type of x before hovering. What you should see: string | number | boolean | undefined — inference unions every element candidate. The exercise teaches that generic inference is only as precise as the arguments: heterogeneous collections may need an explicit type argument to say what you actually mean.