TSer/05 · Generics
CHAPTER 05 · Type parameters and constraints

Generics: one function, many types

Do not commit to a type yet. Write a placeholder, <T>, and let the call site decide what it stands for. Every T in one signature is the same T, so the type of the result stays tied to the type of the input.

§01

The problem: three bad versions of one small function

The task is as small as it gets: return the first element of an array. Without generics there is no good way to write it once.

One action, three different arrays

A small shop's ordering system needs the first order in a list, the first item on a menu, and the first order in a history. It is the same action three times, over three different array types. Without generics you have three ways to write it, and all three are unsatisfying.

Option 1 · one copy per type
1function firstString(arr: string[]): string | undefined {
2 return arr[0];
3}
4function firstNumber(arr: number[]): number | undefined {
5 return arr[0];
6}
7function firstOrder(arr: Order[]): Order | undefined {
8 return arr[0];
9}
10// The three bodies are identical, character for character.
11// A fourth array type means a fourth copy.
Option 2 · any, and the type is gone
1function first(arr: any[]): any {
2 return arr[0];
3}
4
5const x = first(["boba", "coconut jelly"]);
6// x is any. A string went in, but the type did not come out.
7x.toFixed(2);
8// Nothing is reported here. The call fails when the program runs.
Option 3 · a generic, and the type stays
1function first<T>(arr: T[]): T | undefined {
2 return arr[0];
3}
4
5const x = first(["boba", "coconut jelly"]);
6// x is string | undefined. The type came out with the value.
7x.toFixed(2);
8// 'x' is possibly 'undefined'.
9// Property 'toFixed' does not exist on type 'string'. Did you mean 'fixed'?

Look at the right-hand version. One function handles every array type, and the input and the output stay linked: give it a string[] and you get back something built from string; give it an Order[] and you get back something built from Order. That link is what the middle version threw away. The next section slows a single call down to show how it is made.

§02

Leave a hole: what <T> actually does

T is a placeholder for a type. It is filled in at the call site, not where the function is written. Because one signature reuses the same placeholder, the compiler can keep the input and the output in step.

Filling the hole: one generic call in slow motion
function first<T>(arr: T[]): T | undefined
The signature only. <T> declares the placeholder; the two later Ts are uses of that same placeholder. Nothing is filled in yet. Press Next to bring in a call.
1 / 4
Declare it
<T>

The angle brackets after the function name declare one placeholder and give it the name T. It is called a type parameter: a parameter like any other, except that it holds a type instead of a value.

Use it
arr: T[] → T

Every T in the signature refers to that one placeholder. That is the promise: what the array holds and what comes back are the same. any cannot state this.

Fill it
at the call site

Whoever writes the function does not know what T will be, and does not need to. Each call supplies its own arguments, and the compiler works out T for that call alone.

pair.ts · one placeholder means one type
1function pair<T>(a: T, b: T): T[] {
2 return [a, b];
3}
4
5pair("small", "large"); // ok, T = string
6pair("small", 42);
7// Argument of type 'number' is not assignable
8// to parameter of type 'string'.

The compiler reads T = string from the first argument, then checks the second against it. If you want two independent types, declare two placeholders: pair<A, B>(a: A, b: B). There is a lab for that.

The name T is only a convention

T stands for Type. You can call it Item or Row instead, exactly as a value parameter can be called x or count. Other common short names are K and V for a key and a value, and E for an element. The names are short because the placeholder often really could be anything. When it does mean something specific, use a real name: Paginated<Order> reads better than Paginated<T> at a use site.

§03

Filling the hole: inference first, explicit when needed

Most of the time you never see T being filled in. The compiler reads it from the arguments. Writing the type argument by hand is the exception, and it is worth knowing when it is required.

calls.ts · the same function, three calls
1// 1. Let the compiler infer. This is the normal case.
2const a = first(["boba", "coconut jelly"]);
3// T = string, so a is string | undefined.
4
5// 2. Write the type argument yourself.
6const b = first<string>(["boba", "coconut jelly"]);
7// Same result. The angle brackets add nothing here.
8
9// 3. The array is empty, so there is nothing to read a type from.
10const c = first([]); // T = never, so c is undefined
11const d = first<string>([]); // T = string, so d is string | undefined
Inference reads the arguments. That is its only source. An empty array carries no element type, so T is inferred as never and the result is undefined. This is not an error, but it is rarely what you wanted. Seeing never where you did not expect it is a good signal to write the type argument.

When there is nothing at all to infer from

An empty array is still an argument, so the compiler has something to work with. The harder case is a type parameter that appears only in the return type. Then no argument mentions it, and the compiler falls back to unknown.

A type parameter with no inference site
1// T appears only in the return type. No parameter mentions T,
2// so a call gives the compiler nothing to infer from.
3function parseJson<T>(text: string): T {
4 return JSON.parse(text);
5}
6
7const o1 = parseJson('{"total": 25}');
8// T falls back to unknown, so o1 is unknown.
9o1.total;
10// 'o1' is of type 'unknown'.
11
12const o2 = parseJson<{ total: number }>('{"total": 25}');
13// o2 is { total: number }. Here the type argument is required.

This shape is common in code that parses or fetches data. Be careful with it: parseJson promises to return a T, but nothing checks that the parsed text actually has that shape. The type argument is an assertion by the caller, not a guarantee by the compiler. Chapter 03 covers how to check such a value before trusting it.

The rule of thumb: let inference do the work. Write the type argument only when the arguments cannot supply one (an empty array, a parameterless call, a type parameter used only in the return type), or when the inferred type is not the one you want.

§04

Constraints: not every type may fill the hole

A completely open placeholder has a cost: inside the function you can do almost nothing with it. extends narrows what may be passed in, and in return the body gets to use what is guaranteed.

longest.ts · without a constraint, and with one
1// The goal: return whichever value is longer.
2// With no constraint, T could be any type at all, so .length is refused.
3function longestBroken<T>(a: T, b: T): T {
4 return a.length >= b.length ? a : b;
5 // Property 'length' does not exist on type 'T'.
6}
7
8function longest<T extends { length: number }>(a: T, b: T): T {
9 return a.length >= b.length ? a : b; // allowed: every T has length
10}
11
12longest("Boba milk tea", "Four Seasons tea"); // T = string
13longest([1, 2, 3], [4, 5]); // T = number[]
14longest({ length: 3 }, { length: 7 }); // T = { length: number }
15longest(10, 100);
16// Argument of type 'number' is not assignable
17// to parameter of type '{ length: number; }'.

extends here does not mean inheritance

T extends { length: number } reads as: T may be any type, as long as it is assignable to { length: number }. It does not say that T is a subclass of anything, and no class is involved. The test is the structural test from the previous chapter: does the type have a length property of type number? string does. number[] does. An anonymous { length: 12 } does.

One more thing the constraint does not do: it does not replace T with the constraint. longest("a", "b") returns string, not { length: number }. The constraint is only a condition on the argument. The placeholder still holds the full type that was passed in.

The constraint gate: not every type may fill the hole
<T extends { length: number }>
longest(a: T, b: T): T
longest compares the .length of two values, so its placeholder carries a condition: whatever fills it must have length: number. Pick a candidate above and send it through.
getProp.ts · keyof and indexed access
1function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
2 return obj[key];
3}
4
5const order = { item: "Boba milk tea", price: 18 };
6
7getProp(order, "topping");
8// Argument of type '"topping"' is not assignable
9// to parameter of type '"item" | "price"'.
10
11const p = getProp(order, "price"); // p is number
12const i = getProp(order, "item"); // i is string
Two placeholders. T is the object. K is constrained to keyof T, which is the union of that object's key names. The constraint is what makes this safe: because K can only be a key that T really has, the indexed access T[K] is always a type that exists, and a wrong key name is rejected before the program runs. keyof and T[K] are covered properly in chapter 07.
§05

Generic types, interfaces, and classes

Functions are not the only place a placeholder can go. type, interface, and class can all take type parameters. Structures with a fixed outer shape and a varying inside are the usual reason to reach for one.

containers.ts · one shape, many contents
1type Size = "small" | "medium" | "large";
2type Order = { id: number; item: string; size: Size };
3type MenuItem = { name: string; price: number };
4
5// A response envelope: code and msg are fixed, data changes.
6type ApiResponse<T> = { code: number; msg: string; data: T };
7
8// A page of results: the caller decides what the list holds.
9type Paginated<T> = {
10 list: T[];
11 page: number;
12 total: number;
13};
14
15type OrderPage = Paginated<Order>; // a page of orders
16type MenuRes = ApiResponse<MenuItem[]>; // the menu endpoint's response
17
18const page1: OrderPage = {
19 list: [{ id: 1, item: "Boba milk tea", size: "large" }],
20 page: 1,
21 total: 42,
22};
23
24type Wrong = Paginated;
25// Generic type 'Paginated' requires 1 type argument(s).
A generic type must be given its type argument wherever it is used. Paginated<Order> is a type; Paginated on its own is not.
class.ts · a generic class, and a generic method
1// A generic class. T is chosen once, when the instance is created,
2// and every member of that instance then uses the same T.
3class Basket<T> {
4 private items: T[] = [];
5 add(item: T): void { this.items.push(item); }
6 all(): T[] { return this.items; }
7}
8
9const b = new Basket<string>();
10b.add("boba"); // ok
11b.add(42);
12// Argument of type 'number' is not assignable to parameter of type 'string'.
13
14// A generic method on a plain class. U is chosen once per call,
15// so two calls on the same instance can use different types.
16class Counter {
17 countOf<U>(items: U[], match: (v: U) => boolean): number {
18 return items.filter(match).length;
19 }
20}
21
22const c = new Counter();
23c.countOf([1, 2, 3], (v) => v > 1); // U = number
24c.countOf(["a", "b"], (v) => v === "a"); // U = string
The difference is when the placeholder is fixed. On a generic class it is fixed once per instance, and every member shares it, so a Basket<string> only ever accepts strings. On a generic method it is fixed once per call, so the same Counter instance can count numbers and then count strings.
default.ts · a default is not a constraint
1// A default type argument, used when none is written and none is inferred.
2type Labeled<T = string> = { label: string; value: T };
3
4const size: Labeled = { label: "size", value: "large" };
5// No type argument, so T is string.
6
7const stock: Labeled<number> = { label: "stock", value: 42 };
8// T = number.
9
10// A constraint says what T may be. A default says what T is
11// when nothing is supplied. They are separate, and can be combined.
12type Sized<T extends string = "small"> = { v: T };
13
14const s1: Sized = { v: "small" }; // T = "small"
15const s2: Sized<"large"> = { v: "large" }; // T = "large"
<T extends string> limits which types are allowed. <T = string> supplies a type when none is written and none can be inferred. They answer different questions, and <T extends string = "small"> uses both at once.

You have been using generics all along

string[] is shorthand for Array<string>, and Array is a generic interface. Promise<Order> is a value that will be an Order later. Map<string, number> has two placeholders. Once you can read the brackets, the type signatures in the standard library become readable documentation.

One thing that surprises people: Array<Dog> is assignable to Array<Animal>, even though that lets you push a non-Dog into the original array. This is a deliberate decision about how the methods of Array are compared, not something generics do in general. Chapter 02 §04 works through it.

§06

Generics do not exist at runtime, and three common mistakes

One last calibration: a type parameter is a compile-time thing only. Then three ideas that beginners often get wrong.

The TypeScript you write
1function first<T>(arr: T[]): T | undefined {
2 return arr[0];
3}
4
5const x = first<string>(["boba"]);
The JavaScript that runs
1function first(arr) {
2 return arr[0];
3}
4
5const x = first(["boba"]);

Type erasure applies to generics like everything else. <T>, <string>, and every annotation are removed during compilation. So there is no way to ask what T is while the program runs, and no way to write new T() or if (T === String). A generic function does not know its type argument while it runs. The compiler resolved that argument earlier, while it was checking the file. If you need to branch on a type at runtime, you need a real check on a value, which is chapter 03.

Mistake 1: a type parameter that is used only once

function log<T>(x: T): void { console.log(x) } declares T and then mentions it once. It links nothing to anything, so it promises nothing. A useful rule: a type parameter should appear at least twice — linking two parameters, or linking a parameter to the return type. If it appears once, (x: unknown) says the same thing more honestly.

Mistake 2: thinking a generic is just any

any gives up on the type: whatever goes in, what comes out is unchecked. A generic keeps the type: T is resolved to a concrete type at the call, and the input and output stay checked all the way through. The two work in opposite directions. They only look alike because both accept many types.

Mistake 3: <T> on an arrow function in a .tsx file

widget.tsx · three ways out
1// In a .tsx file, <T> at the start of an arrow function
2// is read as the opening tag of a JSX element.
3const id = <T>(x: T) => x;
4// JSX element 'T' has no corresponding closing tag.
5// Unexpected token. Did you mean `{'>'}` or `&gt;`?
6
7// A trailing comma removes the ambiguity.
8const ok = <T,>(x: T) => x;
9
10// So does a constraint, because <T extends ...> cannot be a tag.
11const ok2 = <T extends unknown>(x: T) => x;
12
13// A function declaration never has this problem, in any file.
14function ok3<T>(x: T) { return x; }

A plain .ts file does not have this problem, because it has no JSX syntax to be confused with. Neither does a function declaration, in any file.

§07

Labs

Four tasks, all of which run in the TypeScript Playground: fold three copies into one generic, build a paginated container, try a constraint, and use two placeholders at once.

§08

Quiz

Eight questions. After this chapter, a signature like <T extends X = Y> should read as: a placeholder, with a condition on it, and a value to use when none is given.

QUESTION 01 / 8

Which sentence describes best what generics are for?

QUESTION 02 / 8

Given function first<T>(arr: T[]): T | undefined, the call first([9.9, 19.9]) is written without angle brackets. What is T?

QUESTION 03 / 8

"A generic is just any with extra steps." What is the strongest reply?

QUESTION 04 / 8

Given function longest<T extends { length: number }>(a: T, b: T): T, which call is rejected?

QUESTION 05 / 8

Which of these statements about generics are true? (choose all)

QUESTION 06 / 8

A colleague wrote function log<T>(x: T): void { console.log(x) } and asks you to review it. What is the most useful comment?

QUESTION 07 / 8

To put a condition on a type parameter — "T may not be just anything, it has to be assignable to this shape" — you use the keyword ________.

QUESTION 08 / 8

Given function getProp<T, K extends keyof T>(obj: T, key: K): T[K] and const order = { item: "Boba milk tea", price: 18 }, what happens on getProp(order, "topping")?

What to take away from this chapter
  • A type parameter is a placeholder for a type, filled in at the call site. Every T in one signature is the same T, which is what keeps the output type tied to the input type.
  • Generics and any point in opposite directions. any drops the type at the door. A generic carries it from the input through to the output.
  • Inference is the default, and it reads the arguments. Write the type argument yourself when the arguments cannot supply one — an empty array gives never, and a parameter used only in the return type gives unknown.
  • extends in a type parameter list is a constraint, not inheritance: T must be assignable to that shape. A default (<T = string>) is a separate thing, and the two can be combined. K extends keyof T with T[K] is the standard way to read a property safely.
  • Type parameters are erased: after compilation there is no T to inspect, so a generic function does not know its type argument at runtime. A generic class fixes its placeholder per instance; a generic method fixes it per call.