Generics
A generic is a type with a hole in it. They look intimidating and the everyday use is genuinely simple.
You have already used generics without noticing:
const names: Array<string> = []; // Array with a hole filled by string
const scores: Map<string, number> = new Map();
const later: Promise<User> = fetchUser();Array on its own is incomplete — an array of what? The type parameter fills the hole. Writing your own works the same way.
A generic function#
The problem generics solve: without them, a reusable function loses type information.
function first(items: any[]): any {
return items[0];
}
const n = first([1, 2, 3]); // n is any. we lost the number.With a type parameter, the type flows through:
function first<T>(items: T[]): T {
return items[0];
}
const n = first([1, 2, 3]); // number
const s = first(["a", "b"]); // string
const u = first<User>([]); // User, stated explicitly<T> declares the parameter; T[] and : T use it. You almost never pass it explicitly — TypeScript infers it from the argument.
T is only a convention. <Item> is just as valid and often clearer.
Constraints#
An unconstrained T could be anything, so you can barely touch it:
function longest<T>(a: T, b: T): T {
return a.length > b.length ? a : b; // error: T has no 'length'
}extends narrows what T may be:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length > b.length ? a : b;
}
longest("hello", "hi"); // string
longest([1, 2], [1, 2, 3]); // number[]
longest(10, 20); // error — numbers have no lengthRead T extends X as "T is at least an X". The return type is still the specific type passed in, which is the whole point — longest("a", "b") gives you a string, not a {length: number}.
Constraining by key#
keyof plus a constraint gives you type-safe property access:
function pluck<T, K extends keyof T>(item: T, key: K): T[K] {
return item[key];
}
const user = { id: "1", email: "a@b.com", age: 36 };
pluck(user, "email"); // string
pluck(user, "age"); // number
pluck(user, "nope"); // error: not a key of the objectkeyof T is the union of T's property names — here "id" | "email" | "age". T[K] is the type of that property. This pattern turns up constantly in real code.
Generic interfaces and classes#
interface Repository<T> {
findById(id: string): Promise<T | null>;
save(item: T): Promise<void>;
}
class InMemoryRepo<T extends { id: string }> implements Repository<T> {
private items = new Map<string, T>();
async findById(id: string): Promise<T | null> {
return this.items.get(id) ?? null;
}
async save(item: T): Promise<void> {
this.items.set(item.id, item);
}
}
const users = new InMemoryRepo<User>();
const found = await users.findById("1"); // User | nullOne implementation, fully typed for every entity you use it with.
Defaults#
interface ApiResponse<T = unknown> {
status: number;
data: T;
}
const a: ApiResponse = { status: 200, data: "anything" }; // T is unknown
const b: ApiResponse<User> = { status: 200, data: ada };Note the default is unknown, not any — keep the safe default even here.
A practical example#
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as T;
}
const user = await fetchJson<User>("/api/users/1"); // typed as UserWhen not to use a generic#
The most common mistake is reaching for one where a plain type would do:
function log<T>(message: T): void { // pointless — T is never used
console.log(message);
}
function log(message: string): void {} // just say what you meanA type parameter earns its place only when it appears at least twice — usually once in a parameter and once in the return type, so it can carry information between them. If it appears once, delete it.
Generated code over-reaches here regularly, producing elaborate generic signatures for functions that take one concrete type. Push back towards the simple version.
Exercise#
// Write a generic function `groupBy` that:
// - takes an array of T and a key K (a key of T whose value is a string)
// - returns Record<string, T[]>
// Type it so that groupBy(users, "role") compiles and groupBy(users, "nope") does not.
// write your code hereCommon questions#
When should I add a type parameter?#
When the same type needs to appear in more than one place in the signature — a parameter and the return, or two parameters that must match. If it appears only once, it is doing nothing and a concrete type is clearer.
What does extends mean here — is it inheritance?#
No. In a generic constraint it means "assignable to", so T extends { length: number } reads as "T must have at least a numeric length". It is a bound on what may be substituted, not a class relationship.
Why is my inferred type wider than I wanted?#
Usually because the argument was inferred loosely — a string rather than a literal. as const on the argument, or a T extends string constraint, keeps the narrow literal type instead of widening it.
Get the TypeScript agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for TypeScript. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.