Generics
A type that is filled in by the caller. This is what lets the heap you wrote in the DSA course hold anything at all without giving up on checking.
By the end: Write a function or class that works over any type, and keep the types connected.
A generic is a type that the caller fills in.
That is the whole idea, and it is worth stating plainly because generics have a reputation for difficulty they do not deserve. The difficulty in real code comes from people using them where they are not needed. Used for their actual purpose — writing one function or class that works over many types without giving up on checking — they are straightforward.
If you did the algorithms course, this is the lesson that lets the heap you wrote hold anything at all.
The problem generics solve
Say you want a function that returns the first element of an array. Without generics you have two bad options.
// Option 1: pick a type, and write the function again for every other type.
function firstNumber(values: number[]): number | undefined {
return values[0];
}
function firstString(values: string[]): string | undefined {
return values[0];
}
console.log(firstNumber([1, 2]), firstString(['a', 'b']));
// Option 2: accept anything, and lose all knowledge of what came back.
function firstAny(values: any[]): any {
return values[0];
}
const value = firstAny([1, 2]);
// `value` is any, so the compiler will let you do anything to it, including
// things that will throw. This compiles and crashes.
console.log('any lets this through:', typeof value.toUpperCase);
Output
// <T> declares a type parameter. The caller supplies it, usually implicitly.
function first<T>(values: T[]): T | undefined {
return values[0];
}
// The compiler infers T from the argument each time.
const n = first([1, 2, 3]); // T is number, so this is number | undefined
const s = first(['a', 'b']); // T is string
const o = first([{ id: 1 }]); // T is { id: number }
console.log(n ?? 'none', s ?? 'none', o?.id ?? 'none');
// And the connection between input and output is preserved, so mistakes are
// still caught.
console.log(s?.toUpperCase() ?? 'none');
Output
function first<T>(values: T[]): T | undefined {
return values[0];
}
// T was inferred as number here, so the result is number | undefined, and
// number has no toUpperCase.
const n = first([1, 2, 3]);
console.log(n?.toUpperCase());
What the compiler says
error TS2339: Property 'toUpperCase' does not exist on type 'number'.
Inference, and when to be explicit
function wrap<T>(value: T): { value: T; at: number } {
return { value, at: Date.now() };
}
// Inferred from the argument. This is the normal case.
console.log(wrap(42).value + 1);
console.log(wrap('text').value.toUpperCase());
// Explicit, when there is nothing to infer from.
const empty = first<string>([]);
console.log('explicit on an empty array:', empty ?? 'none');
// Explicit, when you want something wider than the inference would give.
const widened = wrap<string | number>('text');
console.log('widened:', widened.value);
function first<T>(values: T[]): T | undefined {
return values[0];
}
Output
More than one type parameter
// A pair, where the two halves are independent types.
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const p = pair('count', 42);
console.log(p[0].toUpperCase(), p[1].toFixed(0));
// A map function, which is where the pay-off is clearest: the callback's
// return type determines the array's element type.
function mapArray<In, Out>(values: In[], transform: (value: In) => Out): Out[] {
const out: Out[] = [];
for (const value of values) out.push(transform(value));
return out;
}
const lengths = mapArray(['a', 'bb', 'ccc'], (s) => s.length);
const labels = mapArray([1, 2], (n) => `#${n}`);
console.log(lengths.reduce((a, b) => a + b, 0));
console.log(labels.join(' '));
Output
Default type parameters
interface Response<Body = string> {
status: number;
body: Body;
}
// Uses the default.
const text: Response = { status: 200, body: 'ok' };
// Overrides it.
const json: Response<{ id: number }> = { status: 201, body: { id: 7 } };
console.log(text.body.toUpperCase());
console.log(json.body.id);
// Defaults are useful on functions too, though less often needed.
function collect<T = string>(...items: T[]): T[] {
return items;
}
console.log(collect('a', 'b'));
console.log(collect<number>(1, 2));
Output
Generic interfaces and type aliases
type Result<T, E = string> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseNumber(input: string): Result<number> {
const n = Number(input);
return Number.isNaN(n) ? { ok: false, error: `not a number: ${input}` } : { ok: true, value: n };
}
interface ValidationError {
field: string;
message: string;
}
function parseAge(input: string): Result<number, ValidationError> {
const n = Number(input);
if (Number.isNaN(n)) return { ok: false, error: { field: 'age', message: 'not a number' } };
if (n < 0) return { ok: false, error: { field: 'age', message: 'must not be negative' } };
return { ok: true, value: n };
}
const a = parseNumber('42');
console.log(a.ok ? a.value + 1 : a.error);
const b = parseAge('-1');
// The error type is different here, and the compiler knows which is which.
console.log(b.ok ? b.value : `${b.error.field}: ${b.error.message}`);
Output
Generic classes: the heap, typed
Here is the payoff for anyone who came from the algorithms course. That heap had a comparator so it could hold anything — but nothing checked that the comparator matched the contents. Now it does.
class Heap<T> {
private items: T[] = [];
private compare: (a: T, b: T) => number;
constructor(compare: (a: T, b: T) => number) {
this.compare = compare;
}
get size(): number {
return this.items.length;
}
peek(): T | undefined {
return this.items[0];
}
push(value: T): void {
this.items.push(value);
let i = this.items.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
// The ! here is honest: the loop bounds guarantee both indices exist,
// and the compiler cannot see that. Lesson 07 covers why.
if (this.compare(this.items[parent]!, this.items[i]!) <= 0) break;
[this.items[parent], this.items[i]] = [this.items[i]!, this.items[parent]!];
i = parent;
}
}
pop(): T | undefined {
if (this.items.length === 0) return undefined;
const top = this.items[0]!;
const last = this.items.pop()!;
if (this.items.length > 0) {
this.items[0] = last;
let i = 0;
for (;;) {
const left = 2 * i + 1;
const right = left + 1;
let best = i;
if (left < this.items.length && this.compare(this.items[left]!, this.items[best]!) < 0) best = left;
if (right < this.items.length && this.compare(this.items[right]!, this.items[best]!) < 0) best = right;
if (best === i) break;
[this.items[best], this.items[i]] = [this.items[i]!, this.items[best]!];
i = best;
}
}
return top;
}
}
// Numbers.
const numbers = new Heap<number>((a, b) => a - b);
for (const n of [5, 1, 4, 1, 9, 2]) numbers.push(n);
const drained: number[] = [];
while (numbers.size > 0) drained.push(numbers.pop()!);
console.log('sorted:', drained);
// Objects, with a comparator on a field. T is inferred from the comparator.
interface Task {
name: string;
priority: number;
}
const tasks = new Heap<Task>((a, b) => a.priority - b.priority);
tasks.push({ name: 'deploy', priority: 3 });
tasks.push({ name: 'fix build', priority: 1 });
tasks.push({ name: 'review', priority: 2 });
console.log('most urgent:', tasks.peek()?.name);
console.log('order:', [tasks.pop()?.name, tasks.pop()?.name, tasks.pop()?.name]);
Output
And the mistake that JavaScript would have let you make silently.
class Heap<T> {
private items: T[] = [];
constructor(private readonly compare: (a: T, b: T) => number) {}
push(value: T): void { this.items.push(value); }
get size(): number { return this.items.length; }
}
interface Task { name: string; priority: number }
const tasks = new Heap<Task>((a, b) => a.priority - b.priority);
// A plain number is not a Task. In JavaScript this would have been pushed
// happily and then thrown inside the comparator on the next push.
tasks.push(42);
console.log(tasks.size);
What the compiler says
error TS2345: Argument of type 'number' is not assignable to parameter of type 'Task'.
A first look at constraints
T on its own means “absolutely anything”, which means you can do almost nothing with it. If you need to use the value, you must say what you are assuming about it.
function longest<T>(a: T, b: T): T {
// T could be a number, a boolean, null. There is no .length to read.
return a.length >= b.length ? a : b;
}
console.log(longest('alpha', 'be'));
What the compiler says
error TS2339: Property 'length' does not exist on type 'T'. error TS2339: Property 'length' does not exist on type 'T'.
// "T, whatever it is, has a numeric length property."
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
// Works for anything with a length, and returns the specific type given.
console.log(longest('alpha', 'be'));
console.log(longest([1, 2, 3], [1]));
// And the return type is the input type, not the constraint, so this is fine.
console.log(longest('alpha', 'be').toUpperCase());
// A number has no length, so it is rejected at the call site.
console.log(longest({ length: 5, label: 'a' }, { length: 2, label: 'b' }).label);
Output
When not to use a generic
This section matters more than it looks, because unnecessary generics are the main reason people find the feature unpleasant.
The test: a type parameter has to appear at least twice. Generics exist to express a relationship — between two parameters, or between a parameter and the return type. A type parameter used exactly once expresses no relationship, and is a more confusing way of writing something simpler.
// Used once, in the parameter position. This is just `unknown`, spelled
// obscurely — and worse, it silently accepts anything.
function logIt<T>(value: T): void {
console.log('logged:', value);
}
// What it should have been.
function logItBetter(value: unknown): void {
console.log('logged:', value);
}
logIt(1);
logItBetter(1);
// Used once, and immediately discarded. The <T> achieves nothing at all.
function countOf<T>(values: T[]): number {
return values.length;
}
function countOfBetter(values: readonly unknown[]): number {
return values.length;
}
console.log(countOf([1, 2]), countOfBetter([1, 2]));
// A constraint that is really just a parameter type. If you never use the
// specific type, do not introduce a name for it.
function nameOf<T extends { name: string }>(item: T): string {
return item.name;
}
function nameOfBetter(item: { name: string }): string {
return item.name;
}
console.log(nameOf({ name: 'Ada', extra: 1 }), nameOfBetter({ name: 'Grace' }));
Output
Generics are erased too
class Box<T> {
constructor(private value: T) {}
get(): T { return this.value; }
map<U>(transform: (value: T) => U): Box<U> {
return new Box(transform(this.value));
}
}
const numberBox = new Box(21);
const stringBox = numberBox.map((n) => `value is ${n * 2}`);
console.log(stringBox.get());
console.log(numberBox.map((n) => n + 1).get());
What this compiles to
class Box {
value;
constructor(value) {
this.value = value;
}
get() { return this.value; }
map(transform) {
return new Box(transform(this.value));
}
}
const numberBox = new Box(21);
const stringBox = numberBox.map((n) => `value is ${n * 2}`);
console.log(stringBox.get());
console.log(numberBox.map((n) => n + 1).get());
Output
Your turn
- Write
function last<T>(values: T[]): T | undefined. Confirm the compiler tracks the type through by calling it with numbers and then calling.toFixed()on the result. - Write
function zip<A, B>(as: A[], bs: B[]): [A, B][]. Check that destructuring the result gives you the right type in each position. - Take the
Heap<T>from this lesson and add atoSorted(): T[]method that drains a copy. You will need to think about whether it should mutate. - Write
function pluck<T>(items: T[], read: (item: T) => string): string[]and use it on an array of objects without annotating the callback parameter. - Write a generic that only appears once in its signature, then rewrite it without the generic. Confirm the two behave the same and decide which you would rather read.
- Try to write
function sum<T>(values: T[]): Tthat adds them up. Read the error, then work out what constraint would be needed — and why that turns out to be harder than it looks.
Recap
- A generic is a type the caller fills in.
Tis an ordinary placeholder with a scope, and it can have a default and a constraint. - Type arguments are almost always inferred. Be explicit only when there is nothing to infer from, or when you want something wider.
- A type parameter must appear at least twice to be earning its keep. Used once, it expresses no relationship and should be a plain parameter type or
unknown. extendsstates what you are assuming aboutT. Constrain to the shape you actually use, not to a list of types you can think of.- A generic class ties several signatures together at once, which is why containers benefit far more from generics than one-line functions do.
- Type arguments are erased. Nothing at run time can tell you what
Twas, so if you need to know, store it as a value.