All posts
JavaScript & TypeScript··10 min read

TypeScript Generics, From Confusing to Comfortable

Generics are where TypeScript stops being JavaScript with types. A practical, example-first guide to generic functions, constraints, and inference.

By

On this page

The first time generics clicked for me, I was deleting code. I had a getUser, a getOrder, and a getInvoice that were byte-for-byte identical except for the return type. Three functions, one shape. Generics let me write it once and keep every call site fully typed. That's the whole pitch: a generic is a type with a hole in it, and the compiler fills the hole based on how you call it.

If you've been writing TypeScript as "JavaScript with annotations" — : string here, : number there — generics are the line you cross to start writing types that react to your data. This is the guide I wish I'd had: every concept attached to a helper you'd actually ship.

A type parameter is just an argument for types

Look at the most overused example, then immediately make it useful. The <T> is a parameter. It gets a value (a type) when you call the function, exactly like a regular argument gets a value when you call it.

function identity<T>(value: T): T {
  return value;
}
 
const a = identity("hello"); // a: string
const b = identity(42);      // b: number

You almost never write identity. But you write its cousin constantly — a function that takes a thing, does work, and hands back the same thing with its type intact:

function tap<T>(value: T, fn: (value: T) => void): T {
  fn(value);
  return value;
}
 
const port = tap(parseInt(process.env.PORT ?? "3000", 10), (p) =>
  console.log(`Listening on ${p}`),
); // port: number

The payoff: port is number, not any and not some widened mess. The T flowed from the first argument, through the callback, into the return. You wrote zero annotations at the call site. That flow — input determines T, and T determines everything downstream — is the entire mental model. Hold onto it.

Constraints: extends means "at least this shape"

A bare <T> accepts literally anything, which means you can't do anything with it. The moment you try value.id, the compiler stops you, because not every T has an id. You fix that with a constraint: T extends SomeShape reads as "T is anything, as long as it's assignable to SomeShape."

Here's a typed pick, the kind you write when you don't want to pull in a utility library for one function:

function pick<T extends object, K extends keyof T>(
  obj: T,
  keys: readonly K[],
): Pick<T, K> {
  const result = {} as Pick<T, K>;
  for (const key of keys) {
    result[key] = obj[key];
  }
  return result;
}
 
const user = { id: 1, name: "Ada", email: "ada@example.com", role: "admin" };
const card = pick(user, ["id", "name"]);
// card: { id: number; name: string }
 
pick(user, ["id", "phone"]);
//                ^^^^^^^ Type '"phone"' is not assignable to type 'keyof typeof user'

Two things are happening. K extends keyof T constrains the keys to only the keys that exist on the object, so a typo is a compile error, not a runtime undefined. And Pick<T, K> builds a return type that contains exactly the keys you asked for and their real value types. Rename email to emailAddress on the source object and every pick(user, ["email"]) lights up red. That's the difference between a typed helper and a string-keyed footgun.

keyof T gives you the union of an object's keys ("id" | "name" | "email" | "role" above). Indexed access — T[K] — gives you the type at a key. Together they're the workhorses of generic object code.

Inference, and how to stop fighting it

Inference is TypeScript looking at your arguments and figuring out T so you don't have to spell it. It's usually right. When it's wrong, you have three levers, in order of preference: change the argument, add a constraint, or annotate explicitly.

A groupBy shows inference doing real work:

function groupBy<T, K extends PropertyKey>(
  items: readonly T[],
  getKey: (item: T) => K,
): Record<K, T[]> {
  const groups = {} as Record<K, T[]>;
  for (const item of items) {
    const key = getKey(item);
    (groups[key] ??= []).push(item);
  }
  return groups;
}
 
const orders = [
  { id: 1, status: "paid" as const },
  { id: 2, status: "pending" as const },
  { id: 3, status: "paid" as const },
];
 
const byStatus = groupBy(orders, (o) => o.status);
// byStatus: Record<"paid" | "pending", { id: number; status: "paid" | "pending" }[]>

T is inferred from the array. K is inferred from what the callback returns. Because I used as const on the statuses, K narrows to the literal union "paid" | "pending" instead of widening to string, and the result keys are precise. Drop the as const and K becomes string — still correct, just less specific. That's the lever "change the argument": the shape of your data guides inference more than any annotation.

One trap worth knowing: inference flows from arguments, not from the variable you assign to. This fails the way beginners don't expect:

const empty = groupBy([], (x) => x); // T inferred as never — usually not what you want

With no elements, there's nothing to infer T from. Fix it by typing the argument ([] as Order[]) or passing the parameter explicitly (groupBy<Order, string>(...)). Reach for explicit type arguments last — they're a sign inference lost the thread, and they rot when signatures change.

Default type parameters

Defaults let a generic be ergonomic for the common case and powerful for the rare one. Syntax mirrors default function arguments: <T = Fallback>.

interface ApiResponse<T = unknown> {
  data: T;
  status: number;
  requestId: string;
}
 
function ok(): ApiResponse {
  // T defaults to unknown — fine when you don't care about the body
  return { data: null, status: 204, requestId: crypto.randomUUID() };
}
 
function fetchUser(): Promise<ApiResponse<{ id: number; name: string }>> {
  // T is specified — the common case when you do care
  return fetch("/api/user").then((r) => r.json());
}

Default to unknown, never any. unknown forces a check before use; any silently disables the compiler and the bug ships. The whole reason you turned on TypeScript was to not ship that bug.

Putting it together: a typed apiClient

This is where generics stop being academic. A real fetch wrapper that carries the response type through to the caller:

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
 
interface RequestOptions<TBody = undefined> {
  method?: HttpMethod;
  body?: TBody;
  signal?: AbortSignal;
}
 
async function apiClient<TResponse, TBody = undefined>(
  path: string,
  options: RequestOptions<TBody> = {},
): Promise<TResponse> {
  const res = await fetch(`https://api.example.com${path}`, {
    method: options.method ?? "GET",
    headers: { "content-type": "application/json" },
    body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
    signal: options.signal,
  });
 
  if (!res.ok) {
    throw new Error(`Request failed: ${res.status} ${res.statusText}`);
  }
  return res.json() as Promise<TResponse>;
}
 
// Caller declares the shapes once; everything downstream is typed.
interface User {
  id: number;
  name: string;
}
 
const user = await apiClient<User>("/users/1");
console.log(user.name); // string, autocompleted
 
const created = await apiClient<User, { name: string }>("/users", {
  method: "POST",
  body: { name: "Grace" }, // body is checked against { name: string }
});

Two type parameters, TResponse and TBody, with TBody defaulting to undefined so GET calls stay clean. The res.json() as Promise<TResponse> assertion is the honest seam: the network returns any, and you are vouching for the shape. Generics don't validate runtime data — if you need that guarantee, pipe the result through a Zod schema. But for the typing ergonomics across hundreds of call sites, this pattern earns its keep every day.

A typed store with keyof and mapped reads

Mapped and conditional types sound exotic; you'll mostly meet them inside keyof-driven helpers. Here's a tiny createStore with typed get/set and per-key subscriptions:

type Listener<T> = (value: T) => void;
 
function createStore<TState extends object>(initial: TState) {
  let state = { ...initial };
  const listeners = new Map<keyof TState, Set<Listener<unknown>>>();
 
  return {
    get<K extends keyof TState>(key: K): TState[K] {
      return state[key];
    },
    set<K extends keyof TState>(key: K, value: TState[K]): void {
      state = { ...state, [key]: value };
      listeners.get(key)?.forEach((fn) => fn(value));
    },
    subscribe<K extends keyof TState>(key: K, fn: Listener<TState[K]>): () => void {
      const set = (listeners.get(key) ?? new Set()) as Set<Listener<unknown>>;
      set.add(fn as Listener<unknown>);
      listeners.set(key, set);
      return () => set.delete(fn as Listener<unknown>);
    },
  };
}
 
const store = createStore({ count: 0, user: "anonymous" });
 
store.set("count", 5);       // ok
store.set("count", "five");  // Error: 'string' is not assignable to 'number'
const name = store.get("user"); // name: string
store.subscribe("count", (n) => console.log(n.toFixed(0))); // n: number

Every method is generic over K extends keyof TState, so get("count") returns number, get("user") returns string, and set rejects a value of the wrong type for that specific key. The TState[K] indexed access is doing the heavy lifting — it pulls the exact value type for whichever key you passed. The internal as Listener<unknown> casts are the price of erasing per-key types into one map; that complexity stays hidden, and the public API is fully sound.

When generics are overkill

Generics have a cost: every reader of your signature has to hold T, K, and the constraints in their head. Spend that cost only when it buys real type flow. A quick decision table from things I've actually reverted in review:

SituationReach forWhy
Output type depends on an input typeGenericThis is the core use case
Same logic, many concrete typesGenericOne implementation, full typing
A union covers all the casesPlain union typestring | number beats <T> nobody varies
T appears exactly once in the signatureDrop itA once-used T is just unknown in disguise
You're casting as T everywhere insideStep backThe generic is fighting you, not helping

That fourth row catches the most over-engineering. If T shows up in only one place — say a parameter type and never the return — it isn't relating two things together, so it adds noise without adding safety. function log<T>(x: T): void is strictly worse than function log(x: unknown): void.

The checklist

When you sit down to write a generic, run these in order:

  • Does an output type depend on an input type? If no, you probably want a plain type or a union. Stop here.
  • Does T appear at least twice, tying two positions together? A single-use parameter is unknown wearing a costume.
  • Add the loosest constraint that lets the body compile. T extends object, K extends keyof T, K extends PropertyKey — constrain to the shape you actually touch, no more.
  • Let inference do its job; verify by hovering. If the call site needs explicit <...>, ask whether reshaping the argument (often as const) would let inference recover it.
  • Default to unknown, never any. Defaults make the easy path easy without surrendering safety on the hard path.
  • Count your as casts. A few at honest boundaries (network, erased internal maps) are fine. A pile of them means the types are wrong, not the compiler.

Generics aren't a separate language. They're the same functions you already write, with the types made to move. Once you see T as just another argument the compiler infers for you, the rest is practice. Start by replacing your next copy-pasted, type-only-different function with one generic version, hover the call sites, and watch the inference flow. That's the moment it stops being confusing.