Generics
Often called the hardest TypeScript topic — but the idea is simple once the mental model clicks.
Worked example: getSecondElement
Goal: grab the second item of an array. Watch what happens without generics first — that's what makes the generic version click.
Normal way #1 — one function per type
Written for strings, it works — but only for strings:
function getSecondString(arr: string[]): string {
return arr[1];
}
getSecondString(["a", "b", "c"]); // "b" - fine for strings
getSecondString([1, 2, 3]); // Error: number[] not assignable to string[]
Now I need numbers too, so I copy-paste the same logic with a different type:
function getSecondNumber(arr: number[]): number {
return arr[1];
}
Add booleans, objects… and I'm rewriting the same function forever.
Normal way #2 — the any shortcut
One function for all types, but it throws the type away:
function getSecondAny(arr: any[]): any {
return arr[1];
}
const x = getSecondAny(["a", "b", "c"]); // x is 'any' — no autocomplete, no safety
x.toFixed(2); // no error now… crashes at runtime
Reusable, but every guarantee is gone — x could be anything.
The generic way — one function, every type, still safe
<T> is a placeholder that gets filled in per call:
function getSecondElement<T>(arr: T[]): T {
return arr[1];
}
const a = getSecondElement(["a", "b", "c"]); // T = string → a is 'string'
const b = getSecondElement([1, 2, 3]); // T = number → b is 'number'
const c = getSecondElement([true, false]); // T = boolean → c is 'boolean'
a.toUpperCase(); // OK: a is string
b.toFixed(2); // OK: b is number
One definition, works for any array, and the return type matches whatever went in — the reusability of any with the safety of a hand-written type.
The problem generics solve
You want a function or type that works with many types while staying type-safe. Without generics you'd either write it once per type (repetitive) or fall back to any (unsafe). Generics give you a third option: a type placeholder, filled in when the code is used.
function identity<T>(arg: T): T {
return arg;
}
const s = identity<string>("myString"); // T = string → returns string
const n = identity(42); // T inferred as number
<T> is a type variable — a placeholder for a type that the caller (or inference) supplies. Whatever goes in comes back out with its type intact.
Generics vs any — the key contrast
any throws type information away. A generic remembers it. That's the whole point: reusability without losing safety.
function firstAny(arr: any[]): any { return arr[0]; }
const x = firstAny(["a", "b"]); // x is 'any' — no help
function first<T>(arr: T[]): T { return arr[0]; }
const y = first(["a", "b"]); // y is 'string' — type preserved
A real, everyday generic
You've already used generics — querySelector takes one so it knows what element you get back:
const input = document.querySelector<HTMLInputElement>(".input");
// input is HTMLInputElement | null — so input.value is typed
Reusable generic: unknown API responses
Most APIs wrap every response in the same envelope — success, message, and a data field. Only data changes shape per endpoint (a user here, a list of products there). I don't want to retype the envelope each time, and I definitely don't want data: any.
Without a generic — pick your poison
Either data: any (envelope typed, payload unsafe) or a brand-new interface for every endpoint (envelope duplicated forever):
interface UserResponse { success: boolean; message: string; data: { id: number; name: string } }
interface ProductResponse { success: boolean; message: string; data: { id: number; price: number } }
// …one more for every endpoint. Same envelope, copy-pasted.
With a generic — write the envelope once, plug in the payload
T is the part I don't know up front; the caller fills it in:
interface ApiResponse<T> {
success: boolean;
message: string;
data: T; // ← the only part that changes per endpoint
}
interface User { id: number; name: string }
interface Product { id: number; price: number }
const userRes: ApiResponse<User> = await getJson("/api/user/1");
const listRes: ApiResponse<Product[]> = await getJson("/api/products");
userRes.data.name; // string
listRes.data[0].price; // number
userRes.success; // boolean — envelope typed once, reused everywhere
I can push T one level up so the fetch helper itself is reusable — the type flows from the call all the way to data:
async function getJson<T>(url: string): Promise<ApiResponse<T>> {
const res = await fetch(url);
return res.json();
}
const user = await getJson<User>("/api/user/1"); // user: ApiResponse<User>
user.data.name; // typed as string
Genuinely don't know the shape yet? Default to ApiResponse<unknown> — not any — then narrow with a type guard before touching data. unknown forces the check; any skips it and lets a runtime crash through.
Constraining a generic with extends
Sometimes T shouldn't be anything — you need it to at least have certain properties. extends sets that lower bound:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest("abc", "de"); // Valid: strings have length
longest([1, 2], [3]); // Valid: arrays have length
longest(1, 2); // Error: numbers have no 'length'
A generic is a type parameter: you pass a type in (explicitly or by inference), and it flows through the function or type so the result is exactly typed. Reusable like
any, but safe.