Generics
Writing reusable, type-safe code with generics: generic functions, interfaces and classes, constraints with extends, default type parameters and inference.
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.
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
Tappears only once in a signature, it links nothing:function log<T>(value: T): voidis justfunction log(value: unknown): voidwith extra noise. -
Prefer inference to explicit type arguments.
Write
firstOf(readings), notfirstOf<Reading>(readings), unless inference picks the wrong type or has nothing to go on. -
Use meaningful names when there are several.
Tis fine alone;Map<K, V>andApiResult<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
Treally is aT; 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.