Skip to main content

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 envelopesuccess, 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.