Why generics

Some code does not care what kind of value it handles: return the first item of a list, keep a queue, cache a value, group records by a key. The general idea and its history are explained under generics in C# and generics in Java; the problem looks the same in TypeScript. Without generics you can write the function once for any, which switches type checking off:

function firstOf(items: any[]): any {
  return items[0];
}

const meterId = firstOf(["E-1001", "E-1002"]);
const kWh = firstOf([4.2, 3.1]);
console.log(meterId.toLowerCase());
console.log(kWh.toLowerCase());     // compiles: kWh is any
e-1001
TypeError: kWh.toLowerCase is not a function

The compiler accepted a call that could never work, because any forgets what went in. Writing one function per type (firstString, firstNumber, …) would keep the types but duplicate the code. Generics give you both: one implementation, and the compiler still knows that an array of numbers gives back a number.

Generic functions

A generic function declares one or more type parameters in angle brackets after its name and uses them in its parameter and return types. A type parameter is a placeholder name for a type, by convention T, that each call fills in with a real type, the type argument. It works like the type parameters of C# and Java, with one difference covered below: it is gone when the program runs.

function firstOf<T>(items: readonly T[]): T | undefined {
  return items[0];
}

const meterId = firstOf(["E-1001", "E-1002"]);   // T is string
const kWh = firstOf([4.2, 3.1]);                 // T is number
console.log(meterId?.toLowerCase());
console.log(kWh?.toFixed(2));
console.log(firstOf<string>([]));
e-1001
4.20
undefined

The return type T | undefined is honest about empty arrays, so the calls use optional chaining. The mistake from the any version is now caught, because T carries the element type through to the result:

function firstOf<T>(items: readonly T[]): T | undefined {
  return items[0];
}

const kWh = firstOf([4.2, 3.1]);
console.log(kWh?.toLowerCase());
example.ts(6,18): error TS2339: Property 'toLowerCase' does not exist on type 'number'.

Inside the function, T can be anything, so the code may only do what works for every type: store it, pass it on, return it. Calling a string method on a plain T is refused; the fix is a constraint (below).

function shout<T>(value: T): string {
  return value.toUpperCase();
}
example.ts(2,16): error TS2339: Property 'toUpperCase' does not exist on type 'T'.

Arrow functions and methods can be generic too: const firstOf = <T,>(items: T[]) => items[0]; (the comma only matters in .tsx files, where <T> alone would look like a JSX tag).

Type argument inference

Type argument inference is the compiler working out the type arguments of a generic call from the values you pass, so you do not have to write them. It is the generic counterpart of ordinary type inference. Most generic calls in real code, including all the array methods (map, filter, reduce), rely on it.

When several arguments share one type parameter, they must agree. Here T is inferred as string from the first argument, and the second one does not fit:

function samePair<T>(a: T, b: T): [T, T] {
  return [a, b];
}

const pair = samePair("E-1001", 4.2);
example.ts(5,33): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

If a mix is really what you want, say so with an explicit type argument: samePair<string | number>("E-1001", 4.2) compiles. Write type arguments yourself in two situations: to widen what inference would pick, as here, and when there is nothing to infer from. A new Queue() without arguments leaves T as unknown, which accepts anything and gives nothing useful back:

class Queue<T> {
  #items: T[] = [];
  enqueue(item: T): void { this.#items.push(item); }
  dequeue(): T | undefined { return this.#items.shift(); }
}

const queue = new Queue();          // no type argument, nothing to infer from
queue.enqueue("INC-10234");
queue.enqueue(42);                  // accepted: T is unknown
console.log(queue.dequeue()?.toUpperCase());
example.ts(10,30): error TS2339: Property 'toUpperCase' does not exist on type '{}'.

(After the ?. check, unknown minus null and undefined is shown as {}: "any value that is not nullish".) Writing new Queue<string>() fixes both problems: the 42 is rejected and the result is a string.

Generic interfaces, type aliases and classes

A generic type is an interface, type alias or class with type parameters, such as ApiResult<T>. Every use names the type argument, and each one is a different type: an ApiResult<Ticket> holds a ticket, an ApiResult<number> a number. You already use the built-in ones: Array<T> (written T[]), Map<K, V>, Set<T> and Promise<T>.

interface ApiResult<T> {
  status: number;
  data: T;
}

interface Ticket {
  id: string;
  title: string;
}

const ticketResult: ApiResult<Ticket> = {
  status: 200,
  data: { id: "INC-10234", title: "No power in Utrecht" },
};
const countResult: ApiResult<number> = { status: 200, data: 17 };
console.log(ticketResult.data.title);
console.log(countResult.data + 1);

type Pair<A, B> = [A, B];                     // two type parameters
const reading: Pair<string, number> = ["E-1001", 4.2];
console.log(reading);

const tariffs = new Map<string, number>([["DAY", 0.32], ["NIGHT", 0.25]]);
console.log(tariffs.get("NIGHT"));
No power in Utrecht
18
[ 'E-1001', 4.2 ]
0.25

A generic class declares its type parameters after the class name; every field and method can use them. A queue of outage tickets, with the items in a private field:

class Queue<T> {
  #items: T[] = [];

  enqueue(item: T): void {
    this.#items.push(item);
  }

  dequeue(): T | undefined {
    return this.#items.shift();
  }

  get size(): number {
    return this.#items.length;
  }
}

const tickets = new Queue<string>();
tickets.enqueue("INC-10234");
tickets.enqueue("INC-10235");
console.log(tickets.dequeue(), tickets.size);
tickets.enqueue(10236);
example.ts(21,17): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

The program is not run, because the compiler found the last line first. Remove it and it prints INC-10234 1. A Queue<string> is a queue that only accepts strings, checked at every call.

One generic class Queue of T is written once. The type checker uses it as two different types, Queue of string whose enqueue takes a string, and Queue of number whose enqueue takes a number. Both become the same single JavaScript class Queue at run time, without any T. class Queue<T> written once Queue<string> enqueue(item: string) Queue<number> enqueue(item: number) compile time (type checker) run time (JavaScript) class Queue one class, no T
A generic type is one definition that the type checker treats as a family of types. After compilation only the plain JavaScript class remains.

Constraints with extends

A generic constraint, written T extends Something, limits the type arguments to types that are assignable to Something. In return, the function body may use everything Something promises. It is the counterpart of C#'s where clauses and Java's bounded type parameters; because TypeScript types are structural, the constraint is usually a shape, not a class name.

function longest<T extends { length: number }>(a: T, b: T): T {
  return b.length > a.length ? b : a;
}

console.log(longest("E-1001", "E-10002"));     // strings have a length
console.log(longest([4.2, 3.1], [1.0]));       // so do arrays
console.log(longest(10, 20));                  // numbers do not
example.ts(7,21): error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.

Why not simply write the parameter type as the shape? Compare these two functions that both return the reading with the highest usage. The generic version returns the caller's type, so the extra meterId property is still known after the call:

interface Measured {
  kWh: number;
}

function highest<T extends Measured>(items: readonly T[]): T {
  return items.reduce((best, item) => (item.kWh > best.kWh ? item : best));
}

const readings = [
  { meterId: "E-1001", kWh: 4.2 },
  { meterId: "E-1002", kWh: 6.8 },
  { meterId: "E-1003", kWh: 3.1 },
];

const top = highest(readings);
console.log(top.meterId, top.kWh);
E-1002 6.8
interface Measured {
  kWh: number;
}

function highest(items: readonly Measured[]): Measured {
  return items.reduce((best, item) => (item.kWh > best.kWh ? item : best));
}

const readings = [
  { meterId: "E-1001", kWh: 4.2 },
  { meterId: "E-1002", kWh: 6.8 },
];

const top = highest(readings);
console.log(top.meterId);
example.ts(15,17): error TS2339: Property 'meterId' does not exist on type 'Measured'.

The object at run time still has its meterId, but the non-generic signature threw that knowledge away. A good rule: use a type parameter when it links two places (an argument and the result, or two arguments); when it would appear only once, a plain type is simpler and just as safe.

Constraining one parameter by another

A type parameter can be constrained by another one. The classic case is reading a property by name: K extends keyof T only allows names that exist on T, and the result type T[K] is the type of exactly that property. Both operators are explained in Type Operators.

function getField<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const customer = { id: 4471, name: "Amara Okafor", city: "Utrecht" };
const city = getField(customer, "city");   // string
const id = getField(customer, "id");       // number
console.log(city.toUpperCase(), id + 1);
getField(customer, "town");
example.ts(9,20): error TS2345: Argument of type '"town"' is not assignable to parameter of type '"city" | "id" | "name"'.

Default type parameters

A default type parameter gives a type parameter a fallback, written <T = SomeType>, that is used when the type argument is neither written nor inferred. It works like a default parameter value, but for types. Defaults make common cases shorter:

class Cache<V = string> {
  #entries = new Map<string, V>();

  set(key: string, value: V): this {
    this.#entries.set(key, value);
    return this;
  }

  get(key: string): V | undefined {
    return this.#entries.get(key);
  }
}

const labels = new Cache();                     // V = string (the default)
labels.set("E-1001", "Main meter, Utrecht");
const usage = new Cache<number>();              // V = number
usage.set("E-1001", 4.2).set("E-1002", 6.8);
console.log(labels.get("E-1001")?.toUpperCase());
console.log(usage.get("E-1002")?.toFixed(1));
MAIN METER, UTRECHT
6.8

A default of unknown is a safe choice for data whose shape the caller should state: code that forgets the type argument cannot use the data without checking it first.

interface ApiResult<T = unknown> {
  status: number;
  data: T;
}

const raw: ApiResult = { status: 200, data: { id: "INC-10234" } };
console.log(raw.data.id);
example.ts(7,13): error TS18046: 'raw.data' is of type 'unknown'.

Type parameters with a default must come after those without one, just as optional function parameters come last. A parameter can have both a constraint and a default: <T extends object = Record<string, unknown>>.

Generics are erased

All types disappear when TypeScript is compiled, and type parameters are no exception. The JavaScript for Queue<T> is a single class Queue; nothing in it knows whether a queue was meant for strings or numbers. Code that tries to use T as a value does not compile:

function create<T>(): T {
  return new T();
}
example.ts(2,14): error TS2693: 'T' only refers to a type, but is being used as a value here.

When generic code needs to do something type-specific at run time, the caller passes it in as a value: a constructor, a factory function, or a check. A common case is reading JSON, which arrives as unknown data: the generic function takes a type guard that decides, item by item, whether the data really has the expected shape.

function parseList<T>(json: string, isItem: (value: unknown) => value is T): T[] {
  const data: unknown = JSON.parse(json);
  if (!Array.isArray(data)) {
    throw new Error("Expected a JSON array");
  }
  return data.filter(isItem);
}

interface Reading {
  meterId: string;
  kWh: number;
}

function isReading(value: unknown): value is Reading {
  return (
    typeof value === "object" && value !== null &&
    "meterId" in value && typeof value.meterId === "string" &&
    "kWh" in value && typeof value.kWh === "number"
  );
}

const json = '[{"meterId":"E-1001","kWh":4.2},{"meterId":"E-1002","kWh":"n/a"}]';
const readings = parseList(json, isReading);
console.log(readings.length, readings[0].meterId, readings[0].kWh);
1 E-1001 4.2

The second record, with "n/a" as its usage, is dropped by a real check at run time. A signature such as parseList<T>(json: string): T[] without the check would only pretend: it would return whatever the JSON contained, labeled as T[]. Validating data at this kind of boundary is the subject of Async Code and Typing API Data.

Using generics well

Generics are easy to overuse. A few habits keep them readable:

  • Every type parameter should earn its place. If T appears only once in a signature, it links nothing: function log<T>(value: T): void is just function log(value: unknown): void with extra noise.
  • Prefer inference to explicit type arguments. Write firstOf(readings), not firstOf<Reading>(readings), unless inference picks the wrong type or has nothing to go on.
  • Use meaningful names when there are several. T is fine alone; Map<K, V> and ApiResult<TData, TError> read better than <T, U>.
  • Constrain as little as possible. Ask for the members you use ({ kWh: number }), not a large interface the function does not need.
  • A generic signature is not a check. Nothing at run time ensures that data labeled T really is a T; see the previous section.

Two newer features are worth recognizing when you meet them: const type parameters (<const T>, TypeScript 5.0) infer literal types as if the argument had been written with as const, and the utility type NoInfer<T> (5.4) stops one parameter from taking part in inference. Generics are also the building blocks of the utility types and of mapped and conditional types.