A generic is a type parameter — an argument to a type, rather than to a value. The reason to reach for one is always the same: there is a relationship between two types that would otherwise be lost, and the generic is how you say what it is.
The problem they solve
Here is a function that fetches JSON. Without generics it has to say something about what comes back, and both options are bad:
async function request(path: string): Promise<any> { /* … */ }
const product = await request('/api/products/1');
product.nmae; // no error. any is contagious.Be honest instead, and the caller pays. This does not compile without an assertion at every call site:
async function request(path: string): Promise<unknown> { /* … */ }
const product = await request('/api/products/1');
product.name; // error — and now every caller must assertThe first gives up checking. The second is honest but pushes an assertion onto every one of the forty call sites. What you actually want is for the caller to say what it expects once, and for the function to hand it back:
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {That is the pizza app's HTTP layer. T is a placeholder, chosen by the caller, and
the return type is expressed in terms of it. One implementation serves every endpoint.
Reading the syntax
<T> after the function name declares the parameter; it can then be used
anywhere a type can. T is conventional for a single one, but a descriptive name is
better once there are two — <TResponse, TBody> reads far better than
<T, U>.
Calling it, you can supply the type argument explicitly:
const me = await api.get<User>('/api/auth/me', { auth: true });me is a User. Nothing was asserted and nothing was checked at runtime —
which is the honest caveat from lesson 1, and
exactly why the app keeps this claim in one file rather than scattering it.
Inference: usually you do not pass it
When the type parameter appears in the arguments, TypeScript works it out:
function first<T>(items: T[]): T | undefined {
return items[0];
}
first([1, 2, 3]); // T inferred as number
first(product.sizes); // T inferred as ProductSizeYou almost never write first<number>([1, 2, 3]). The reason
api.get<User> does need its argument is that T appears only in the
return type — there is nothing to infer it from, so it defaults to unknown unless you
say.
That is a useful rule of thumb for reading generic code: if the type parameter is not in the parameters, the caller has to supply it.
Passing a type parameter through
The most instructive part of the app's HTTP layer is what sits on top of request:
export const api = {
get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(path, { ...opts, method: 'GET' }),
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(path, { ...opts, method: 'POST', body }),Each method declares its own <T> and forwards it. The caller writes
api.post<AuthenticationResponse>(…) and gets that type back, while the method
fixes the parts it has already decided.
Notice how little else this costs. There is one implementation of the fetch, the token header and the error handling; the five wrappers add a verb each. Without the type parameter you would need either five copies or an assertion at every call site.
Constraints
An unconstrained T could be anything, so you can do almost nothing with it. To use a
property, require it:
// This does not compile.
function label<T>(item: T): string {
return item.name;
// ~~~~
// Property 'name' does not exist on type 'T'.
}The last line of this one does not compile, which is the point of the constraint:
function label<T extends { name: string }>(item: T): string {
return item.name;
}
label(product); // fine
label(topping); // fine
label(42); // errorextends here means "at least" rather than inheritance. It is the same structural
rule as everywhere else — anything with a name: string qualifies.
The question worth asking is why this is generic at all, since
label(item: { name: string }) would also work. The answer is what the function
returns: if it returned the item rather than a string, the generic version preserves the
exact type and the non-generic one flattens it to { name: string }. Which leads to the
rule below.
Defaults, and multiple parameters
A type parameter can have a default, used when it is neither supplied nor inferable:
interface ApiResult<T = unknown> {
data: T;
status: number;
}And there can be several, each constrained independently:
function pluck<T, K extends keyof T>(item: T, key: K): T[K] {
return item[key];
}
pluck(product, 'name'); // string
pluck(product, 'sizes'); // ProductSize[]
pluck(product, 'nope'); // does not compile — not a key of ProductThat one is worth studying, because it is the shape most "clever" generics take.
K extends keyof T says the key must be one of T's own keys, and
T[K] is the type of that property. The return type is therefore different for every
call — which no overload could reasonably express.
Lesson 15 covers
keyof and indexed access properly.
Generic interfaces and classes
Types can take parameters too. The app uses one for the API's pagination envelope:
/** Spring's paginated envelope, trimmed to what the UI uses. */
export interface Page<T> {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}Page<Order> and Page<AdminUser> then describe two different
endpoints, and combining it with the HTTP wrapper reads exactly as you would hope:
const page = await api.get<Page<Order>>('/api/admin/orders?page=0', { auth: true });
page.content[0].total; // numberClasses work the same way, with the parameter on the class and usable in every member.
When not to reach for one
Generics have a cost — they make a signature harder to read — so they should be earning something. Two rules cover most cases.
If the type parameter appears only once, you do not need it.
function log<T>(value: T): void { /* … */ } // pointless
function log(value: unknown): void { /* … */ } // says the same thing, more clearlyA type parameter used once connects nothing to anything. The whole purpose is to tie two positions together — a parameter to a return type, or one parameter to another.
If a union would do, use the union. T extends 'DELIVERY' | 'CARRYOUT'
is almost always worse than OrderType, unless you are specifically preserving which
member was passed.
The test to apply: what relationship am I preserving? For request<T>
it is "the type the caller asked for is the type they get back". For pluck it is
"the return type is the property type of the key you passed". If you cannot finish that sentence,
you probably want a plain parameter type.
Where inference goes wrong
Three situations account for most confusing generic errors.
Inference widens. TypeScript infers the general type, not the literal, unless something asks otherwise:
function wrap<T>(value: T) {
return { value };
}
wrap('DELIVERY'); // { value: string } — not { value: 'DELIVERY' }If you need the literal preserved, constrain the parameter so a literal type is the only sensible inference:
function wrap<const T extends string>(value: T) {
return { value };
}
wrap('DELIVERY'); // { value: 'DELIVERY' }The const modifier on the type parameter, added in TypeScript 5.0, tells the compiler
to infer as though the argument had as const on it. Before it existed, the same job
needed T extends string plus a call site that wrote the assertion itself.
Inference happens from every argument at once. When a parameter appears twice, both call sites contribute and you can get a union you did not want:
function pair<T>(a: T, b: T) { /* … */ }
pair(1, 'two'); // T becomes string | number, and it compilesThat is usually not the intent. Two parameters, <A, B>, says what you meant.
A default parameter can be inferred away. When you want one position to define
T and another merely to be checked against it, NoInfer — TypeScript 5.4 —
is the tool:
function pick<T>(options: T[], selected: NoInfer<T>) { /* … */ }
pick(['SMALL', 'MEDIUM'], 'LARGE'); // now an error, as it should beWithout it, 'LARGE' contributes to T as well, widening it to include
the very value the call should have rejected.
A note on the syntax in .tsx files
One practical wrinkle. In a .tsx file, <T> on an arrow function
is ambiguous with JSX, and the parser guesses wrong:
const first = <T>(items: T[]) => items[0]; // parsed as a JSX elementThe fix is a trailing comma, which is odd-looking and standard:
const first = <T,>(items: T[]) => items[0];Or use a function declaration, which has no such ambiguity. This does not arise in
.ts files — which is why the app's api.ts can write
get: <T>(path: string, …) without the comma.
Reading someone else's generic signature
Library types can look forbidding. A recipe that works on most of them: substitute a concrete type for each parameter and read it again.
function pluck<T, K extends keyof T>(item: T, key: K): T[K];Put T = Product and K = 'sizes' through it and you get
(item: Product, key: 'sizes') => ProductSize[], which is obvious. The generic version
is that, said once for every possible pair.
The other half of reading them is knowing which parameters the caller supplies and which are inferred, which is the rule from earlier: anything appearing in the parameter list gets inferred, anything appearing only in the return type has to be passed.
Two shorthands that show up constantly and are worth recognising on sight.
<T extends unknown[]> constrains to any array, usually so a rest parameter can be
forwarded. And <T extends (...args: never[]) => unknown> constrains to any
function, which is how wrappers like ReturnType are declared — the
never[] is deliberate, and makes the constraint accept every function rather than only
those taking any.
And a naming note: single letters are conventional but not compulsory. In a signature with more
than one parameter, spelling them out — <TResponse, TBody> — costs nothing and
saves the reader scrolling back to work out which is which.
Next
The Utility Types Worth Knowing — the generics
that ship with TypeScript, including the Omit that has appeared in three lessons now
without explanation.