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.
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: numberYou 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: numberThe 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 wantWith 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: numberEvery 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:
| Situation | Reach for | Why |
|---|---|---|
| Output type depends on an input type | Generic | This is the core use case |
| Same logic, many concrete types | Generic | One implementation, full typing |
| A union covers all the cases | Plain union type | string | number beats <T> nobody varies |
T appears exactly once in the signature | Drop it | A once-used T is just unknown in disguise |
You're casting as T everywhere inside | Step back | The 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
Tappear at least twice, tying two positions together? A single-use parameter isunknownwearing 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 (oftenas const) would let inference recover it. - Default to
unknown, neverany. Defaults make the easy path easy without surrendering safety on the hard path. - Count your
ascasts. 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.