TypeScript – Generics

August 12, 20268 min readUpdated 9/5/2026

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 assert

The 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 ProductSize

You 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);        // error

extends 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 Product

That 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;   // number

Classes 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 clearly

A 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 compiles

That 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 be

Without 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 element

The 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.