TypeScript
Generics and Utility Types
Generic functions, constraints, keyof and indexed access types, the utility types worth reaching for, and how to tell a helpful type from a clever one.
The first generic most people write is a copy of something they saw: function identity<T>(value: T): T. It compiles and teaches nothing. Then the real request arrives: you have a list of orders, you need the totals out of it, then the ids, then the customer emails, and you would rather not write three functions that differ in one word.
The version with a string parameter works and throws the type away. pluck(orders, "total") typed as unknown[] means every caller casts, and the cast is where the bug goes. Generics keep the connection between the argument and the result, so the compiler knows that asking for total gives numbers and that a key which does not exist is an error at the call site rather than at runtime.
All output below came from TypeScript 6.0.3 on Node 24.11.1.
A type parameter is a value the caller supplies, at the type level
Start from the version that loses information:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
type Order = { id: string; total: number; note?: string };
const orders: Order[] = [
{ id: "a1", total: 19.5 },
{ id: "b2", total: 4, note: "gift" },
];
console.log(pluck(orders, "total"));
console.log(pluck(orders, "note"));
console.log(pluck(orders, "id"));
[ 19.5, 4 ]
[ undefined, 'gift' ]
[ 'a1', 'b2' ]
Read the signature as three separate claims. T is the element type of the array. K extends keyof T says the second argument must be one of the property names of T, which is why keyof T is the constraint rather than a separate check inside the body. The return type T[K][] is an indexed access type: the type of the property named K on T, wrapped in an array because the function returns one value per item.
Nothing in the body needed an annotation or a cast. item[key] is accepted because the compiler already knows key is a key of item from the constraint, and the inferred return type matches what the callers see. Inference runs the other way too: T is inferred from the array you pass, so you rarely write the type arguments at all.
The constraint is what turns a runtime surprise into a compile error. Remove it and the body stops compiling, because a type parameter with no constraint has no properties:
function longestWrong<T>(items: T[]): number {
return items[0].length;
}
error TS2339: Property 'length' does not exist on type 'T'.
The message says T, not the type you passed, which is the tell that a constraint is missing. Give it one and the error moves to the caller, where it belongs:
function longest<T extends { length: number }>(items: T[]): number {
return items.reduce((max, item) => Math.max(max, item.length), 0);
}
console.log(longest(["ana", "bo", "cyrus"]), longest([[1, 2], [3]]));
5 2
The constraint describes what the function needs, not what the caller has. { length: number } admits strings and arrays and excludes numbers at the call site. Write the smallest constraint the body requires; a wide one that happens to fit today cannot be narrowed later without breaking callers.
Passing a bad key fails at the call, passing a bad assumption fails at the assignment
Two different mistakes are possible with pluck, and the compiler reports them in different places:
type Order = { id: string; total: number };
const orders: Order[] = [{ id: "a1", total: 19.5 }];
console.log(pluck(orders, "customer"));
const loose: string[] = pluck(orders, "total");
error TS2345: Argument of type '"customer"' is not assignable to parameter of type 'keyof Order'.
error TS2322: Type 'number[]' is not assignable to type 'string[]'.
Type 'number' is not assignable to type 'string'.
The first error is the constraint doing its job: "customer" is not a key of Order, so the call is rejected before anything runs. The second shows the return type being computed rather than guessed. pluck(orders, "total") is number[], and assigning it to string[] fails with the element type named. A version returning unknown[] compiles both lines and turns the first bug into undefined in a template string somewhere else.
Generic interfaces and classes describe a family, not one type
Classes take type parameters in the same position as functions, and the structural rules from the previous lesson still decide what satisfies an interface:
interface Entity {
id: string;
}
interface Repository<T extends Entity> {
get(id: string): T | undefined;
all(): T[];
save(item: T): void;
}
class MemoryRepository<T extends Entity> implements Repository<T> {
private items = new Map<string, T>();
get(id: string): T | undefined {
return this.items.get(id);
}
all(): T[] {
return [...this.items.values()];
}
save(item: T): void {
this.items.set(item.id, item);
}
}
type Customer = { id: string; name: string };
const customers = new MemoryRepository<Customer>();
customers.save({ id: "c1", name: "Ana" });
customers.save({ id: "c2", name: "Bo" });
for (const customer of customers.all()) {
console.log(customer.id.toUpperCase(), customer.name);
}
C1 Ana
C2 Bo
The constraint on the class is what makes item.id usable inside save. Without T extends Entity, the parameter type is a bare T and there is no id to read. The implements clause is documentation more than enforcement here, since structural typing would accept the class either way, but it is what makes a typo in a method signature fail next to the method rather than at a distant call site.
Type parameters can carry defaults, which is how a result type stays usable when the error case is almost always the same:
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function parseId(raw: string): Result<string> {
const trimmed = raw.trim();
if (trimmed === "") return { ok: false, error: new Error("empty id") };
return { ok: true, value: trimmed };
}
for (const raw of [" c9 ", ""]) {
const parsed = parseId(raw);
console.log(parsed.ok ? parsed.value : parsed.error.message);
}
c9
empty id
Result<string> means Result<string, Error> because of the default. The discriminated union from the previous lesson then does the narrowing: inside the ternary, parsed.ok is true in one branch and false in the other, so both parsed.value and parsed.error are legal, each in its own branch and nowhere else.
Generics are not free. Each type parameter is something every reader of the signature has to hold in their head, and a signature with four of them usually wants a named type instead. If a type parameter appears only once in the signature it adds nothing, because the caller never sees the connection. Two or more uses is where a generic earns its place.
Utility types exist to name a transformation you would otherwise retype
The compiler ships a set of generic types for the shapes you meet constantly. They are not a checklist to apply everywhere; each one answers a different question about what a value is allowed to be missing.
| Type | What it produces | Use it when |
|---|---|---|
Partial<T> |
Every property becomes optional. | A draft, a patch, or an update payload with only the changed fields. |
Required<T> |
Every property becomes required. | A validated record, after a parse step has filled the defaults. |
Readonly<T> |
Every property becomes read-only, one level deep. | A value crossing into code that must not mutate it. |
Pick<T, K> |
Only the named properties, still required. | A function that needs two fields out of twelve. |
Omit<T, K> |
Every property except the named ones. | A record for storage or transport where one field does not belong. |
Record<K, V> |
An object whose keys are K and values are V. |
A lookup table, a cache, a map keyed by id. |
Here is one file using all six, so you can see which errors each one moves:
type Order = { id: string; total: number; lines: number; note?: string };
function label(order: Pick<Order, "id" | "total">): string {
return `${order.id} (${order.total})`;
}
function totals(orders: Record<string, Pick<Order, "total">>): number {
return Object.values(orders).reduce((sum, order) => sum + order.total, 0);
}
const draft: Partial<Order> = { id: "c3" };
const frozen: Readonly<Order> = { id: "d4", total: 1, lines: 0 };
const complete: Required<Order> = { id: "e5", total: 2, lines: 1, note: "paid" };
const withoutNote: Omit<Order, "note"> = { id: "f6", total: 3, lines: 1 };
console.log(label(complete), totals({ a: complete, b: withoutNote }), draft, frozen.id);
e5 (2) 5 { id: 'c3' } d4
The runtime output is unremarkable. Every line could be written without the utility types, by retyping the same shape four times. The value is that a change to Order reaches all four, and that the intent is legible in the signature: Pick<Order, "id" | "total"> says this function reads two fields and cannot touch the rest.
What each one catches is worth seeing once. These three come from the declarations above with one mistake added at a time:
error TS2540: Cannot assign to 'total' because it is a read-only property.
error TS2741: Property 'note' is missing in type '{ id: string; total: number; lines: number; }' but required in type 'Required<Order>'.
error TS2353: Object literal may only specify known properties, and 'note' does not exist in type 'Omit<Order, "note">'.
Three traps live in that table. Readonly is shallow: Readonly<Order> stops you assigning order.lines = 3 and does nothing about a mutable array stored in a property. Omit does not prevent extra properties in general; it makes the named one unknown to the type, so the excess-property check from lesson 13 rejects it in a literal and a fresh object from a helper can still smuggle it in. Partial makes every field optional including the ones that identify the record, which is why a Partial<Order> is a fine patch type and a poor thing to pass around as data.
Record deserves its own warning. Record<string, Order> claims every string key holds an Order, and the compiler believes it: byId["nope"].total compiles and throws at runtime. Turn on noUncheckedIndexedAccess and the same expression becomes Order | undefined, which forces the check you needed. That flag is the subject of the next lesson, and this is the reason it exists.
Conditional types with infer let you ask what a type is made of
A conditional type reads like a ternary at the type level, and infer is how you name a piece of the type you matched:
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
async function fetchOrders(): Promise<{ id: string; total: number }[]> {
return [{ id: "a1", total: 2 }];
}
type Orders = Awaited<ReturnType<typeof fetchOrders>>;
type OneOrder = ElementOf<Orders>;
const order: OneOrder = { id: "a1", total: 2 };
console.log(order);
{ id: 'a1', total: 2 }
ReturnType takes a function type and gives back its return type; Awaited unwraps the promise; ElementOf is mine, and it says that if T is an array of something, the result is that something, and otherwise never. The readonly in the pattern is what makes it accept both string[] and readonly string[], and infer U is the position where the element type gets bound to a name.
Matching on a union distributes, which is the behaviour that surprises people:
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
type Mixed = ElementOf<string[] | number[]>;
type Bad = ElementOf<{ id: string }>;
Mixed is string | number, not never, because the conditional is applied to each member of the union separately and the results are joined. Bad is never, which is how a type-level function reports that the input did not match. Distributivity is usually what you want and occasionally the source of a type that silently widens. If you need the union treated as one type, wrap both sides in a tuple: [T] extends [readonly (infer U)[]].
You can also make one of these recursive, and that is where the return on investment starts to fall:
type Path<T> = {
[K in keyof T & string]: T[K] extends object ? K | `${K}.${Path<T[K]>}` : K;
}[keyof T & string];
function get<T, P extends Path<T>>(item: T, path: P): unknown {
return path.split(".").reduce<unknown>((acc, key) => (acc as Record<string, unknown>)[key], item);
}
const order = { id: "a1", customer: { email: "a@b.c", name: "Ana" } };
console.log(get(order, "customer.email"), get(order, "id"));
console.log(get(order, "customer.postcode"));
error TS2345: Argument of type '"customer.postcode"' is not assignable to parameter of type 'Path<{ id: string; customer: { email: string; name: string; }; }>'.
It works. get(order, "customer.email") type-checks, and the misspelled path is rejected. It is also the kind of type I have removed from a codebase twice. The reason is not that it breaks. It is that the error message names Path<{ id: string; customer: ... }>, so the person who typed the wrong key has to know how Path expands to work out which keys were legal. The get body also casts three times to satisfy the compiler, so the type is strong at the boundary and blind inside. Three explicit functions are longer and easier to debug at 2am.
The judgement call, stated plainly: a clever type is worth it when it removes a whole class of runtime bug at the boundary of your program, when its error messages name something the caller can fix, and when the next person can read the definition in under a minute. Otherwise write the boring signature and add the runtime check. Spend the type budget on the code that touches the outside world, where the shapes are not yours to control.
Build a typed lookup over a repository. Start with this and make the whole file compile with strict, no casts, and no any:
type Product = { sku: string; price: number; stock: number; supplier?: string };
interface Store<T extends { sku: string }> {
all(): T[];
find(sku: string): T | undefined;
save(item: T): void;
}
function indexBy<T, K extends keyof T>(items: T[], key: K): Map<T[K], T[]> {
throw new Error("not implemented");
}
function field<T, K extends keyof T>(items: T[], key: K): T[K][] {
throw new Error("not implemented");
}
Implement both functions, then add a ProductPatch type for an update payload that may carry any subset of the fields except sku, and a function that applies a patch without mutating the input. Finish with a Store<Product> implementation backed by Map.
Before you compile, write down what indexBy(products, "supplier") should produce, given that supplier is optional. Then check whether the compiler agrees with you. If it does not, decide which of the two of you is wrong.
Show the solution and the reasoning
Both functions are short. The interesting decisions are in the signatures and in the patch type.
type Product = { sku: string; price: number; stock: number; supplier?: string };
type ProductPatch = Partial<Omit<Product, "sku">>;
function indexBy<T, K extends keyof T>(items: T[], key: K): Map<T[K], T[]> {
const index = new Map<T[K], T[]>();
for (const item of items) {
const bucket = index.get(item[key]) ?? [];
bucket.push(item);
index.set(item[key], bucket);
}
return index;
}
function field<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
function applyPatch(product: Product, patch: ProductPatch): Product {
return { ...product, ...patch };
}
class MapStore<T extends { sku: string }> implements Store<T> {
private items = new Map<string, T>();
all(): T[] {
return [...this.items.values()];
}
find(sku: string): T | undefined {
return this.items.get(sku);
}
save(item: T): void {
this.items.set(item.sku, item);
}
}
ProductPatch is two utility types because the job needs two transformations. Omit<Product, "sku"> removes the identifier, so a patch cannot rename the thing it patches, and Partial then makes the remaining fields optional, so a caller sends only what changed. Writing Partial<Product> alone would allow { sku: "other" }, which is a silent identity change rather than an update. The order of the two matters for reading, not for the result: Partial<Omit<Product, "sku">> and Omit<Partial<Product>, "sku"> produce the same type here. I write the first because it states the intent in the order I think about it.
The spread in applyPatch is why the function does not mutate its argument, and it is also why exactOptionalPropertyTypes matters for this type. With that flag on, { ...product, ...patch } where patch.supplier is undefined produces supplier: undefined, and assigning that to a Product whose supplier is optional-but-not-undefined is an error. The fix is to build the object conditionally or declare the property as supplier?: string | undefined and mean it.
On the question you were asked to answer first: indexBy(products, "supplier") has the type Map<string | undefined, Product[]>, because T[K] for an optional property includes undefined. Products with no supplier land in a single bucket keyed by undefined, which is either exactly what you want or a bug you have not noticed yet. The compiler is right and the type is honest; the fix is at the call site, where you can decide whether to skip products without a supplier or to give them a sentinel key. This is the same mechanism that made pluck(orders, "note") return (string | undefined)[] earlier, and it is the single most useful thing to understand about indexed access types.
What I would not add: a generic Store<T, K extends keyof T, V> parameterised by its index key. The abstraction has one implementation and the third parameter is unused. When a second implementation with a different key arrives, that is the moment to generalise, and the change is small because the constrained interface is already the contract.
Progress
Mark this lesson done and it stays marked in this browser.