Generics
By the end of this lesson
Write reusable code that preserves type information.
Some code does the same job whatever it is given. Take the first item of a list. Group records by a field. Cache a result. The logic does not care whether the items are employees or expenses.
Written without generics, that code has to accept any, and the type of the items is thrown away. Generics let a function stay general while remembering what it was given, so the caller gets a specific type back.
// The type of the items is discarded
const anyEmployees: any[] = loadEmployees();
anyEmployees[0].nmae; // No error. undefined at run time
anyEmployees[0].toFixed(2); // No error. Throws at run time
// Array<Employee> is the same array, with the element type kept
const employees: Array<Employee> = loadEmployees();
employees[0].nmae; // Error: no such property on Employee
employees[0].name.toUpperCase(); // Checked, and the editor can complete it- Array is generic: it takes the element type as a parameter. Array<Employee> and Employee[] mean exactly the same thing, and the bracket form is the usual way to write it.
- With any[], indexing gives you any. Every property and method is permitted, so a misspelled name is accepted and fails later.
- With Array<Employee>, indexing gives you an Employee. The typo is an error where you wrote it, and the editor can offer the real property names.
- This is what preserving type information means, and it is why generics exist. The array code is identical in both cases; only what the checker knows about the contents differs.
function firstOrUndefined<T>(items: T[]): T | undefined {
return items.length > 0 ? items[0] : undefined;
}
const employee = firstOrUndefined(employees); // Employee | undefined
const expense = firstOrUndefined(expenses); // Expense | undefined
if (employee) {
console.log(employee.department); // known to be an Employee
}
// Two type parameters, used together
function pluck<TItem, TKey extends keyof TItem>(items: TItem[], key: TKey): TItem[TKey][] {
return items.map((item) => item[key]);
}
const names = pluck(employees, "name"); // string[]
const amounts = pluck(expenses, "amount"); // number[]
const wrong = pluck(employees, "nmae"); // Error: not a key of Employee- The <T> after the name declares a type parameter — a placeholder for a type that the caller decides.
- T is used in both the parameter and the return type, and that connection is the whole mechanism: whatever type goes in comes back out.
- Nothing is passed explicitly at the call sites. The type is inferred from the argument, so employees gives T = Employee.
- The return is T | undefined because an empty array has no first item. The caller has to check, which is the honest signature for this function.
- pluck has two parameters. keyof TItem is the union of that type's property names, so the second argument can only be a real property — "nmae" fails immediately.
- TItem[TKey] is the type of that property, so the return type follows the key you passed: names is string[] and amounts is number[]. One function, precise types for both.
The pieces and the conventions:
- <T>
- A type parameter. T is the conventional name for a single one; a descriptive name such as TItem is clearer once there are several.
- Inference at the call site
- You rarely write the type argument. It is worked out from what you pass, and can be given explicitly when needed.
- T extends something
- A constraint: T must be assignable to that type. It lets the function body rely on those members, which an unconstrained T cannot.
- keyof T
- The union of T's property names. Turns a string argument into a checked property reference.
- Generic interfaces and types
- Shapes can be generic too — a paged result, a cache, an API envelope. interface Page<T> { items: T[]; total: number }
- Default type arguments
- <T = string> supplies a fallback when the caller provides nothing and nothing can be inferred.
// Without the constraint, item.id would be an error: T might not have one
function indexById<T extends { id: string }>(items: T[]): Map<string, T> {
const byId = new Map<string, T>();
for (const item of items) {
byId.set(item.id, item);
}
return byId;
}
const employeesById = indexById(employees); // Map<string, Employee>
const found = employeesById.get("E-00417"); // Employee | undefined
// Rejected at compile time: no id property
indexById([{ label: "Travel" }, { label: "Equipment" }]);- T extends { id: string } says: whatever T is, it has a string id. That is the minimum this function needs.
- Inside the body, item.id is now allowed. Without the constraint the checker would refuse, because an unconstrained T could be a number.
- The constraint does not narrow the return type. Passing employees still gives Map<string, Employee>, with every other property intact — the function asked for the minimum and kept the rest.
- Map is generic in two parameters, the key type and the value type, so get returns Employee | undefined. The undefined is correct: the id might not be there.
- The last line fails because those objects have no id. The error names the missing property, which points straight at the cause.
Summary
- A type parameter lets one function work with many types while remembering which one it was given
- Array<Employee> keeps the element type; any[] discards it and with it the checking
- Type arguments are usually inferred from the values you pass
- A constraint states the minimum shape the body needs, and keeps the full type for the caller
- Generics cost readability, so reach for them where code is genuinely reused, not by default
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Group by a field, with the types intact
Write groupBy, which takes an array and the name of a property, and returns a Map from that property's value to the items that have it.
Make the key argument checked, so groupBy(expenses, "catgory") fails to compile, and make the returned value's type follow from the key.
Show solution
Two type parameters are needed and both do work. TItem carries the element type through to the returned arrays; TKey ties the key argument to the Map's key type.
TKey extends keyof TItem is what rejects the typo. Without it the argument would be string, and a misspelled property would compile and produce a Map with one undefined key — a bug with no error message.
TItem[TKey] as the Map key means grouping by category gives Map<ExpenseCategory, Expense[]> and grouping by amount gives Map<number, Expense[]>. The precision comes free from the connection between the parameters.
The ?? [] handles the first item for each key. Map.get returns undefined for a missing key, and strict mode makes you deal with it — the same absent-value discipline as everywhere else.
function groupBy<TItem, TKey extends keyof TItem>(
items: TItem[],
key: TKey
): Map<TItem[TKey], TItem[]> {
const groups = new Map<TItem[TKey], TItem[]>();
for (const item of items) {
const groupKey = item[key];
const existing = groups.get(groupKey) ?? [];
existing.push(item);
groups.set(groupKey, existing);
}
return groups;
}
const byCategory = groupBy(expenses, "category");
// Map<"travel" | "equipment" | "training", Expense[]>
const travel = byCategory.get("travel") ?? [];
console.log(travel.length);
groupBy(expenses, "catgory"); // Error: not a key of ExpenseThink about it
Think about it
A helper is written as function firstItem(items: any[]): any. It works, and the tests pass. What has the team given up, and where will they notice?
Show solution
Everything downstream of the call. The result is any, so every property read on it is unchecked — including misspellings and properties that do not exist on the type they actually passed.
They notice it in two places. In the editor, where there are no completions and no hover information for the result, so people go and read the source of whatever they passed in. And in production, where a renamed field breaks at run time instead of at build time, because nothing was checking.
There is a third, quieter cost: the any spreads. Assign the result to a variable, pass it on, and the lack of checking travels with it. A single any in a widely used helper can remove checking from a large part of a codebase without anyone deciding to.
Changing it to <T>(items: T[]): T | undefined keeps the same body. The undefined is an addition rather than an inconvenience — the old signature was claiming an item always exists, which was never true.
Saved in this browser only.