Emmanuel Akinfulubi

Lesson 09 of 1820 min read

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.

Two unsatisfying attempts

// 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


      

The generic version, which has neither problem

// <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


      

The link between argument and return type holds

TS2339
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

Usually inferred, occasionally worth stating

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

Two parameters, and the relationship between them

// 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

A default, for the common case

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

The Result type from lesson 04, now generic in both directions

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.

A binary heap that works over any type, checked

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.

A comparator that does not match the contents

TS2345
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.

You cannot assume anything about an unconstrained T

TS2339
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'.

extends says what you are assuming

// "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.

Three generics that should not be

// 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

All of that machinery, and none of it survives

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

  1. 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.
  2. Write function zip<A, B>(as: A[], bs: B[]): [A, B][]. Check that destructuring the result gives you the right type in each position.
  3. Take the Heap<T> from this lesson and add a toSorted(): T[] method that drains a copy. You will need to think about whether it should mutate.
  4. Write function pluck<T>(items: T[], read: (item: T) => string): string[] and use it on an array of objects without annotating the callback parameter.
  5. 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.
  6. Try to write function sum<T>(values: T[]): T that 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. T is 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.
  • extends states what you are assuming about T. 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 T was, so if you need to know, store it as a value.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.