Generics and Reusable Types
Writing a function or type that works for many types without losing the specific one, constraints that keep it honest, and the generic parameter that should have been a plain argument.
Writing a function or type that works for many types without losing the specific one, constraints that keep it honest, and the generic parameter that should have been a plain argument.
You write a function that takes the first element of an array.
It should work for photos, for strings, for anything. So you
type the parameter as unknown[], and now the caller gets back
unknown and has to assert what they already knew — turning a
helper into a nuisance.
The alternative is a type parameter: a placeholder filled in at each call. By the end of this lesson you will write generic functions and types, know how to constrain them, and know the sign that a generic has stopped helping — because an unnecessary one is worse than none.
function first<T>(items: readonly T[]): T | undefined {
return items[0];
}
first([1, 2, 3]); // number | undefined
first(["a", "b"]); // string | undefined
first(photos); // Photo | undefined<T> declares a type parameter. It appears in the input and in
the output, which is the whole point — the two are linked, so
the checker knows a Photo[] yields a Photo.
Nobody wrote first<Photo>(photos). TypeScript infers T from
the argument, and explicit type arguments are usually noise:
first<number>([1, 2, 3]); // correct, and unnecessaryWrite them only when inference cannot see enough — usually when the type appears solely in the return position.
An unconstrained T can be anything, so you can do almost
nothing with it. extends narrows what is allowed:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest([1, 2], [1, 2, 3]); // number[]
longest("ab", "abc"); // string
longest(1, 2); // error - no `length`T extends { length: number } means "any type with a length".
Inside, you may use .length; outside, the caller gets their
exact type back rather than the constraint.
That last part is the reason to use a generic here at all. This would be simpler:
function longest(a: { length: number }, b: { length: number }) { ... }Returns the constraint.
The signature says { length: number } goes in and
{ length: number } comes out, so that is all the caller
gets — something they may ask the length of and nothing else.
The array they passed in has been forgotten.
Returns what the caller put in.
T is captured from the arguments, checked against the
constraint, and handed straight back — so two number[]
yield a number[].
The constraint governs what the body may do, not what the caller receives.
Constraining to keys is the other common form:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
pluck(photos, "size"); // number[]
pluck(photos, "name"); // string[]
pluck(photos, "nmae"); // error - not a key of Photokeyof T is the union of T's property names. T[K] is the
type of that property. So the return type is computed from the
arguments, and a misspelled key is an error rather than
undefined[].
type ApiResponse<T, E = Error> = {
data: T | null;
error: E | null;
};
type PhotoResponse = ApiResponse<Photo>; // E is Error
type Typed = ApiResponse<Photo, ValidationError>;A default makes a parameter optional at the use site, the same way a default argument does for a value.
Generic types work like generic functions:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parse(text: string): Result<Photo, ValidationError> { ... }That is the discriminated union from the foundations course,
made reusable. A caller cannot reach value without checking
ok, whatever T is.
Bad — a type parameter used once.
function logSize<T extends { size: number }>(item: T): void {
console.log(item.size);
}
function parseJson<T>(text: string): T {
return JSON.parse(text) as T;
}Good — a plain parameter, and an honest return type.
function logSize(item: { size: number }): void {
console.log(item.size);
}
function parseJson(text: string): unknown {
return JSON.parse(text);
}A T that appears once
In logSize, T is in the parameter and nowhere else.
Nothing is linked to anything, so the type parameter, the
constraint and the angle brackets buy exactly what the plain
parameter already did.
A T that only names an assertion
parseJson<Photo>(text) hands back something the checker
calls a Photo with nothing having verified it.
The as T is a claim you are choosing to make, and the
generic makes it look like a feature. Returning unknown
puts the obligation back where it belongs.
The rule: a type parameter must appear at least twice — once to receive information, once to use it. Otherwise delete it.
class Repository<T extends { id: string }> {
private items = new Map<string, T>();
add(item: T): void {
this.items.set(item.id, item);
}
get(id: string): T | undefined {
return this.items.get(id);
}
all(): readonly T[] {
return [...this.items.values()];
}
}
const photos = new Repository<Photo>();
photos.add(photo);
photos.get("abc"); // Photo | undefinedThe parameter is declared on the class and available in every
method. Note the constraint is what allows item.id inside.
Interfaces and type aliases take parameters the same way:
interface Cache<K, V> {
get(key: K): V | undefined;
set(key: K, value: V): void;
}Typing a function that takes another function is where generics earn the most:
function mapValues<T, U>(
items: readonly T[],
transform: (item: T) => U,
): U[] {
return items.map(transform);
}
mapValues(photos, (photo) => photo.name); // string[]T comes from the array, U from what the callback returns,
and the caller annotated nothing — photo is inferred because
transform must take a T.
The same shape for a wrapper that preserves a signature:
function withLogging<A extends unknown[], R>(
fn: (...args: A) => R,
name: string,
): (...args: A) => R {
return (...args: A): R => {
console.log(`calling ${name}`);
return fn(...args);
};
}
const load = withLogging(loadPhoto, "loadPhoto");
load("abc"); // same parameters, same return typeA extends unknown[] captures the whole parameter list. Without
it — typing fn as (...args: any[]) => any — the wrapped
function accepts anything and returns any, which switches off
checking for every caller. That is the most common way type
coverage is lost in a codebase, and it is one line to prevent.
Two situations where the checker cannot work it out.
The parameter appears only in the return type:
function empty<T>(): T[] {
return [];
}
const photos = empty<Photo>(); // must be explicitA literal widens when you did not want it to:
function pick<T>(options: T[]): T { ... }
pick(["a", "b"]); // string, not "a" | "b"
pick(["a", "b"] as const); // "a" | "b"as const freezes the literals, which is the same mechanism
from the enums lesson. A const type parameter does it for the
caller:
function pick<const T>(options: readonly T[]): T { ... }
pick(["a", "b"]); // "a" | "b" - no `as const` neededTHE IDEA
function first<T>(items: readonly T[]): T | undefined
T links the input to the output; inferred at each call
explicit type arguments are usually noise
CONSTRAINTS
<T extends { length: number }> any type with a length
<T, K extends keyof T> K is a property name of T
T[K] the type of that property
the caller still gets their EXACT type back, not the constraint
DEFAULTS AND SEVERAL PARAMETERS
type ApiResponse<T, E = Error> = ...
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
THE RULE
a type parameter must appear at LEAST TWICE
once used? it is doing nothing - use a plain parameter
function parseJson<T>(text: string): T <- an assertion in
disguise; nothing verified anything. Return unknown.
CLASSES AND INTERFACES
class Repository<T extends { id: string }>
interface Cache<K, V>
FUNCTIONS TAKING FUNCTIONS - where generics earn most
function mapValues<T, U>(xs: readonly T[], f: (x: T) => U): U[]
callbacks need no annotation - contextual typing
function wrap<A extends unknown[], R>(fn: (...a: A) => R)
without it, (...args: any[]) => any erases the signature and
makes every caller's result `any`
WHEN INFERENCE NEEDS HELP
the parameter appears only in the return -> pass it explicitly
pick(["a", "b"]) string
pick(["a", "b"] as const) "a" | "b"
function pick<const T> does it for the caller
NAMING
T, U for one or two; named parameters past thatYou can now write functions and types that work for many types without discarding the specific one, constrain them to what you actually use, and recognise the generic that is decoration. The two-appearances rule is the one to apply immediately — it deletes a surprising number of type parameters from most codebases.
Next is Narrowing in Depth, which returns to the mechanism the foundations course introduced and takes it further: type guards you write yourself, exhaustiveness that survives a refactor, and the places narrowing silently stops applying.
Before you move on, find a function in your code typed with
any[] or unknown[] and make it generic. Then check what the
callers receive — in most cases they were asserting the type
back afterwards, and those assertions can now be deleted.