Generics and type operators Foundation
Generics
Write functions, interfaces and classes that work for many types without losing safety, constrain them with extends and keyof, and give them defaults.
BY THE END OF THIS LESSON YOU CAN
- Explain what a type parameter is and why it beats any
- Write generic functions and let TypeScript infer their type arguments
- Write generic interfaces, type aliases and classes
- Constrain a type parameter with extends and keyof, and read T[K]
- Give a type parameter a default, and build a reusable Result type
Why generics
Here is a small helper that returns the first task in a list, and the same helper for users:
interface Task { id: number; title: string }
interface User { id: number; name: string }
function firstTask(items: Task[]): Task | undefined {
return items[0];
}
function firstUser(items: User[]): User | undefined {
return items[0];
}
console.log(firstTask([{ id: 1, title: "Buy milk" }])?.title);
console.log(firstUser([{ id: 7, name: "Ada" }])?.name);
npx tsx why.ts and of the browser terminalBuy milk Ada
The two bodies are identical. Only the types differ. You could write one version with any, but you learned in Basic types what that costs:
function firstAny(items: any[]): any {
return items[0];
}
const title = firstAny(["Buy milk", "Call Ada"]);
try {
console.log(title.toFixed(2));
} catch (error) {
console.log(String(error));
}
npx tsx any-first.ts and of the browser terminalTypeError: title.toFixed is not a function
It compiles, and crashes. The compiler forgot that the list held strings. A generic is the fix: a function (or interface, or class) with a type parameter, a placeholder for a type that the caller fills in.
Generic functions
A type parameter goes in angle brackets before the parameter list. By convention a single one is called T (for "type"):
function first<T>(items: readonly T[]): T | undefined {
return items[0];
}
const title = first(["Buy milk", "Call Ada"]);
const count = first([3, 1, 2]);
const nothing = first<string>([]);
console.log(title?.toUpperCase(), (count ?? 0) + 1, nothing);
npx tsx first.ts and of the browser terminalBUY MILK 4 undefined
When you call first(["Buy milk", "Call Ada"]), TypeScript sees an array of strings, so T becomes string and title is string | undefined. For [3, 1, 2], T is number. This is inference again. With an empty array there is nothing to infer from, so you pass the type yourself: first<string>([]).
A function can have several type parameters. This one turns a list into a Map, looked up by a key that a callback picks:
function indexBy<T, K>(items: readonly T[], keyOf: (item: T) => K): Map<K, T> {
const map = new Map<K, T>();
for (const item of items) map.set(keyOf(item), item);
return map;
}
const users = [
{ id: 1, email: "[email protected]", name: "Ada" },
{ id: 2, email: "[email protected]", name: "Grace" },
];
const byEmail = indexBy(users, (user) => user.email);
const byId = indexBy(users, (user) => user.id);
console.log(byEmail.get("[email protected]")?.name);
console.log(byId.get(1)?.name, byId.size);
npx tsx index-by.ts and of the browser terminalGrace Ada 2
T is inferred from the list and K from what the callback returns: string for byEmail, number for byId. So byId.get("1") would be a type error, which is right, because the map's keys are numbers.
You have used generic types since the first lessons without the name: Array<string>, Promise<Task> and Map<K, T> are all generic types from JavaScript's standard library.
Generic interfaces and types
Interfaces and type aliases can take type parameters too. An API that returns long lists sends them one page at a time. The page shape is the same for tasks and users; only the items differ:
interface Page<T> {
readonly items: readonly T[];
readonly page: number;
readonly totalPages: number;
}
function paginate<T>(all: readonly T[], page: number, size: number): Page<T> {
const start = (page - 1) * size;
return { items: all.slice(start, start + size), page, totalPages: Math.ceil(all.length / size) };
}
const titles = ["Buy milk", "Call Ada", "File taxes", "Fix bug", "Water plants"];
const second: Page<string> = paginate(titles, 2, 2);
console.log(second);
npx tsx page.ts and of the browser terminal{ items: [ 'File taxes', 'Fix bug' ], page: 2, totalPages: 3 }A generic type is like a function for types: Page<string> "calls" Page with T = string, and gives an object whose items are strings.
Constraints: extends and keyof
Inside a generic function, T could be anything, so TypeScript lets you do almost nothing with it. Try to read an id:
function findById<T>(items: readonly T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
npx tsc --noEmit printsfind.ts:2:36 - error TS2339: Property 'id' does not exist on type 'T'.
2 return items.find((item) => item.id === id);
~~
Found 1 error in find.ts:2That is correct: someone could call findById([1, 2, 3], 1), and numbers have no id. A constraint, written T extends …, sets a rule for T: "any type, as long as it has at least this shape".
function findById<T extends { id: number }>(items: readonly T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
function pluck<T, K extends keyof T>(items: readonly T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const tasks = [
{ id: 1, title: "Buy milk", done: false },
{ id: 2, title: "Call Ada", done: true },
];
console.log(findById(tasks, 2)?.title);
console.log(pluck(tasks, "title"));
console.log(pluck(tasks, "done"));
npx tsx find.ts and of the browser terminalCall Ada [ 'Buy milk', 'Call Ada' ] [ false, true ]
T extends { id: number }:Tcan be a task, a user, anything with a numericid. And the result is still the fullT:findById(tasks, 2)?.titleworks.keyof Tis the union ofT's property names, here"id" | "title" | "done".K extends keyof Tmeans "one of those names", andT[K]is the type of that property. Sopluck(tasks, "title")is astring[]andpluck(tasks, "done")aboolean[].
A name that is not a property is refused:
function pluck<T, K extends keyof T>(items: readonly T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
pluck([{ id: 1, title: "Buy milk" }], "titel");
npx tsc --noEmit printspluck-typo.ts:5:39 - error TS2345: Argument of type '"titel"' is not assignable to parameter of type '"id" | "title"'.
5 pluck([{ id: 1, title: "Buy milk" }], "titel");
~~~~~~~
Found 1 error in pluck-typo.ts:5keyof and T[K] get a full section of their own in Advanced and utility types.
A Result type, with defaults
Many functions can fail in expected ways: a port number out of range, a missing record. Instead of throwing, they can return an object that says whether it worked. You wrote one for transfers in Union types in depth: a discriminated union, TransferResult. The problem is that every function needs its own copy. Made generic, one type works for any value and any error.
REASON IT OUT
Which type parameters does a Result need?
Before you read the code, think about parsePort(raw), which gives a port number or an error message, and findUser(id), which gives a user or "not-found" | "suspended".
- What changes from one function's result to the other's? Those are the type parameters.
- Which of them is the same in most functions, and could have a default?
- A helper
ok(value)builds a success. What should its error type be, so that it fits anyResult? - Should a caller be able to read the value without first checking whether it worked?
Show the reasoning
- The value type and the error type:
Result<T, E>. - The error: most simple functions report a message, so
E = stringkeepsResult<number>short, andfindUsercan still sayResult<User, "not-found" | "suspended">. never. A success has no error, andnever, the empty type, fits wherever any error type is expected.- No. The value must only be readable after a check of
ok, which is exactly what a discriminated union gives you.
export type Result<T, E = string> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
function parsePort(raw: string): Result<number> {
const port = Number(raw);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return err(`"${raw}" is not a valid port`);
}
return ok(port);
}
for (const input of ["3000", "99999", "http"]) {
const result = parsePort(input);
console.log(result.ok ? `port ${result.value}` : `error: ${result.error}`);
}
npx tsx result.ts and of the browser terminalport 3000 error: "99999" is not a valid port error: "http" is not a valid port
E = stringis a default type parameter.Result<number>meansResult<number, string>. Pass a second type when you need a different error:Result<User, "not-found" | "suspended">.okreturnsResult<T, never>: a success can never hold an error, so it fits anyResult<T, E>. The same goes forerr.- After
result.ok, TypeScript knows which member you have. You cannot readresult.valuewithout checking first, so you cannot forget the failure case.
Libraries use the same idea with other property names. @zudojs/schema's safeParse never throws on bad data; it returns { success: true, data } or { success: false, issues }, and success is the discriminant:
import { schema } from "@zudojs/schema";
const PortSchema = schema.number().int().min(1).max(65535);
for (const input of [3000, 99999, "3000"]) {
const result = PortSchema.safeParse(input);
console.log(result.success ? `port ${result.data}` : `${result.issues.length} issue(s): ${result.issues[0]?.message}`);
}
npx tsx safe-parse.ts and of the browser terminalport 3000 1 issue(s): Expected <= 65535, received 99999 1 issue(s): Expected number, received string
Runtime validation, later in this course, builds a small schema library like this one yourself.
Generic classes: a repository
Classes can be generic too. Here is a pattern you will meet again in @zudojs/database: a repository, the one place that stores and finds one kind of record. Written once, it works for tasks, users or anything with an id:
interface Entity {
readonly id: number;
}
class MemoryRepository<T extends Entity> {
private readonly items = new Map<number, T>();
async save(item: T): Promise<T> {
this.items.set(item.id, item);
return item;
}
async findById(id: number): Promise<T | undefined> {
return this.items.get(id);
}
async findWhere(match: (item: T) => boolean): Promise<T[]> {
return [...this.items.values()].filter(match);
}
}
interface Task extends Entity { title: string; done: boolean }
interface User extends Entity { email: string }
const tasks = new MemoryRepository<Task>();
await tasks.save({ id: 1, title: "Buy milk", done: false });
await tasks.save({ id: 2, title: "Call Ada", done: true });
const users = new MemoryRepository<User>();
await users.save({ id: 1, email: "[email protected]" });
console.log(await tasks.findById(2));
console.log((await tasks.findWhere((task) => !task.done)).map((task) => task.title));
console.log((await users.findById(1))?.email);
npx tsx repository.ts and of the browser terminal{ id: 2, title: 'Call Ada', done: true }
[ 'Buy milk' ]
[email protected]new MemoryRepository<Task>()fixesTfor that object.tasks.savenow only accepts tasks, andfindByIdreturns aTask. Trytasks.save({ id: 3, email: "x" }): it is a type error.private readonly itemscan only be used inside the class and never replaced. Classes in TypeScript covers these words.- The methods are
asynceven though aMapanswers instantly, because a real database will not. Swapping this class for a database version later will not change any code that uses it.
Practice
TRY IT YOURSELF
A generic findOrFail
Using the Result type from this lesson, write findOrFail<T extends { id: number }>(items: readonly T[], id: number): Result<T> that returns the item, or the error "Item 7 not found".
Write it in the editor, then press Check. Hints and the solution open up once you have checked your code.
HINT 1
const item = items.find((i) => i.id === id);, the same way firstAbove uses find above.
HINT 2
return item ? { ok: true, value: item } : { ok: false, error: `Item ${id} not found` };
SOLUTION
type Result<T, E = string> = { ok: true; value: T } | { ok: false; error: E };
function findOrFail<T extends { id: number }>(items: readonly T[], id: number): Result<T> {
const item = items.find((i) => i.id === id);
return item ? { ok: true, value: item } : { ok: false, error: `Item ${id} not found` };
}
const tasks = [
{ id: 1, title: "Buy milk" },
{ id: 2, title: "Call Ada" },
];
const found = findOrFail(tasks, 2);
const missing = findOrFail(tasks, 7);
if (found.ok) console.log(found.value.title);
if (!missing.ok) console.log(missing.error);
npx tsx find-or-fail.ts and of the browser terminalCall Ada Item 7 not found
TRY IT YOURSELF
Group by a key
Write groupBy<T, K>(items: readonly T[], keyOf: (item: T) => K): Map<K, T[]>. Group tasks by their status and print how many are in each group.
Write it in the editor, then press Check. Hints and the solution open up once you have checked your code.
HINT 1
Start a Map<K, T[]>, and for each item read its key with keyOf(item). Read the existing group with groups.get(key) ?? [].
HINT 2
push the item onto that array, then groups.set(key, group), the same way tally does above. Return groups at the end.
SOLUTION
function groupBy<T, K>(items: readonly T[], keyOf: (item: T) => K): Map<K, T[]> {
const groups = new Map<K, T[]>();
for (const item of items) {
const key = keyOf(item);
const group = groups.get(key) ?? [];
group.push(item);
groups.set(key, group);
}
return groups;
}
type Status = "todo" | "doing" | "done";
const tasks: { title: string; status: Status }[] = [
{ title: "Buy milk", status: "done" },
{ title: "Fix bug", status: "doing" },
{ title: "Call Ada", status: "done" },
];
for (const [status, group] of groupBy(tasks, (task) => task.status)) {
console.log(status, group.length);
}
npx tsx group-by.ts and of the browser terminaldone 2 doing 1
TRY IT YOURSELF
A typed stack
Write a class Stack<T> with push(item: T): void, pop(): T | undefined and a size getter. Use it for an "undo" list of task titles.
Write it in the editor, then press Check. Hints and the solution open up once you have checked your code.
HINT 1
push is one line: this.items.push(item);. pop is also one line: return this.items.pop();, the array method, not this class's own method.
HINT 2
size is already given as a getter reading this.items.length; you do not need to change it.
SOLUTION
class Stack<T> {
private readonly items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
get size(): number {
return this.items.length;
}
}
const undo = new Stack<string>();
undo.push("rename: Buy milk");
undo.push("delete: Call Ada");
console.log(undo.pop(), undo.size);
npx tsx stack.ts and of the browser terminaldelete: Call Ada 1
Recap
- A generic has type parameters (
<T>) that the caller fills in, usually by inference. It keeps full type safety whereanywould lose it. - Functions, interfaces, type aliases and classes can all be generic.
T extends Shapesets a rule forT.K extends keyof Tmeans "one ofT's property names", andT[K]is that property's type.E = stringgives a type parameter a default.Result<T, E>makes failure part of the return type; a generic repository stores any kind of record.
Next: Designing generic APIs, where you decide which type parameters an API should have, and build a toolkit of generic types that fit together.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.