Generics That Scale
Typing higher-order functions, preserving parameter types through wrappers, fluent builders that stay inferable, and generics that read well at the call site rather than the definition.
Typing higher-order functions, preserving parameter types through wrappers, fluent builders that stay inferable, and generics that read well at the call site rather than the definition.
A wrapper adds retries to any async function. It works, and
every function passed through it comes back accepting anything
and returning any — so the twenty call sites that used it are
no longer type-checked, and nothing said so.
Simple generics link one input to one output. The ones that appear in real libraries link parameter lists, chained calls and nested structures, and getting them wrong silently removes checking rather than producing an error. By the end of this lesson you will type higher-order functions, keep inference alive through a chain, and know which patterns stay readable.
Bad — a wrapper that erases what it wraps.
function withRetry(fn: (...args: any[]) => Promise<any>) {
return async (...args: any[]): Promise<any> => {
...
};
}
const load = withRetry(loadPhoto);
load(42, "wrong"); // no error
load("a").nonexistent; // no errorGood — ParamSpec-style
capture with a rest tuple.
function withRetry<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
): (...args: A) => Promise<R> {
return async (...args: A): Promise<R> => {
...
};
}
const load = withRetry(loadPhoto);
load(42); // error - wrong argument typeA extends unknown[] captures the entire parameter list as a
tuple, and spreading it back reproduces the signature exactly —
including parameter names, optionality and rest parameters.
The first version does not merely lose information about itself. Every function passed through it comes back unchecked, so a wrapper applied to twenty functions removes checking from twenty call sites — and it looks like infrastructure rather than a hole. This is the most common way type coverage disappears from a codebase.
For a decorator that adds an argument:
function withContext<A extends unknown[], R>(
fn: (context: Context, ...args: A) => R,
): (...args: A) => R {
return (...args: A): R => fn(currentContext(), ...args);
}The tuple is manipulated directly — one parameter removed from the front — which is exactly what a rest tuple makes possible.
A constraint should narrow what is allowed and keep what the caller gave you:
function pluck<T, K extends keyof T>(items: readonly T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
pluck(photos, "size"); // number[]
pluck(photos, "nmae"); // error - not a key of PhotoK extends keyof T does both. The return type is computed from
both parameters, and a misspelled key is an error rather than
undefined[].
The same shape for a constraint on the value type:
function sumBy<T, K extends KeysMatching<T, number>>(
items: readonly T[],
key: K,
): number {
return items.reduce((total, item) => total + (item[key] as number), 0);
}
sumBy(photos, "size"); // fine
sumBy(photos, "name"); // error - not a numeric keyKeysMatching is the map-then-index idiom from the mapped-types
lesson. Combining it with a constraint is how you accept only
the keys that make sense.
Builders lose their types when each method returns the same type. The fix is to thread the accumulated state:
class QueryBuilder<T, Selected = T> {
select<K extends keyof T>(
...keys: K[]
): QueryBuilder<T, Pick<T, K>> {
...
}
where(predicate: (row: T) => boolean): QueryBuilder<T, Selected> {
...
}
run(): Selected[] { ... }
}const rows = builder<Photo>()
.select("id", "name")
.where((p) => p.size > 1000)
.run();
// { id: string; name: string }[]Each method returns a builder with an updated type parameter, so
the chain accumulates knowledge. where keeps Selected
unchanged and still has the full T for its predicate, which is
the useful property — you can filter on a column you did not
select.
The functional equivalent uses an object type that grows:
Note the as — this is a case where the runtime construction is
correct and the checker cannot verify the spread produces that
type. Contained in one place, behind a signature that is right,
which is the acceptable form of an assertion.
Three tools for when inference goes the wrong way.
NoInfer stops a position contributing a candidate:
Without it, T becomes number | string and the mistake
compiles.
const type parameters give callers literal types:
Explicit defaults keep a signature evolvable, per the library lesson:
And the one to avoid: adding a type parameter that appears only once. The two-appearances rule from the practice course still holds at this level — a parameter receiving information but never using it is decoration.
Three signals.
The signature is longer than the body
A five-line generic signature over a three-line function has the ratio backwards. Two concrete overloads, or two separate functions, are usually clearer.
Callers must write type arguments
If parse<Photo>(...) is required rather than optional,
inference is not working and the generic is a worse spelling
of a parameter.
The error message is unreadable
A constraint violation several levels deep reports the whole instantiation. If a colleague cannot tell what was wrong from the message, the type is not helping them.
That one is a common false economy: it accepts an object,
returns the same type, and does nothing a plain parameter would
not — the constraint is doing all the work and T is
unnecessary.
You can now type wrappers that keep their subject's signature, constraints that compute rather than merely restrict, and chains that accumulate knowledge. The rest-tuple pattern is the one with the widest application — every decorator, middleware and higher-order function in a codebase either uses it or is silently unchecked.
Next is Branded and Nominal Types, which returns to the structural typing from earlier in this course and makes two identical types deliberately incompatible.
Before you move on, find a higher-order function in your code
and check whether it uses any in its signature. If it does,
convert it to the rest-tuple form and see what errors appear at
its call sites. Those errors were always there; nothing was
looking.
PRESERVING A SIGNATURE
<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R
captures the whole parameter list, including names and
optionality
(...args: any[]) => any removes checking from EVERY call site
that uses the wrapper - the most common way coverage is lost
manipulate the tuple to add or drop parameters:
(context: Context, ...args: A) => R -> (...args: A) => R
CONSTRAINTS THAT CARRY INFORMATION
<T, K extends keyof T>(items: T[], key: K): T[K][]
narrows what is allowed AND computes the return
<K extends KeysMatching<T, number>> only numeric keys
CHAINS
each method returns Builder<T, NewState>
the chain accumulates type knowledge
keep the original T available for predicates
functional: T & Record<K, V>, with one contained `as`
ESCAPE HATCHES
NoInfer<T> stop a position deciding T
<const T> literal types for the caller
<T, E = Default> a new parameter without breaking callers
a parameter appearing ONCE is decoration - delete it
WHEN TO STOP
the signature is longer than the body
callers must write explicit type arguments
the error message is unreadable to a colleague
<T extends Record<string, unknown>>(input: T): T
is usually just a parameter with extra steps
METHOD
write the CALL SITE you want, then make the signature produce itfunction withField<T, K extends string, V>(
base: T,
key: K,
value: V,
): T & Record<K, V> {
return { ...base, [key]: value } as T & Record<K, V>;
}
const config = withField(withField({}, "port", 3000), "host", "local");
// { port: number } & { host: string }function fill<T>(items: T[], value: NoInfer<T>): void { ... }
fill([1, 2], "x"); // error - T is decided by itemsfunction defineStatuses<const T extends readonly string[]>(
values: T,
): { list: T; type: T[number] } { ... }
defineStatuses(["a", "b"]); // T is readonly ["a", "b"]function parse<T, E = ValidationError>(schema: Schema<T>): Result<T, E>;function process<T extends Record<string, unknown>>(input: T): T { ... }const rows = query(photos).select("id").run();
// rows should be { id: string }[]