Skip to content
academia.sh

Lesson 12 / 23

Generics

Writing shape-independent structures with type parameters, type argument inference, constraints, the keyof constraint, default type parameters, and generic misuse.

Contents

Every type in the previous topic was fixed: a Log holding Measurement records, a select function filtering Measurement. Yet a log’s structure is independent of the record type — the same code should be able to collect measurements, warnings, or events.

There are two bad ways to get this. Writing a separate log for every record type duplicates code; letting the log use any[] removes type safety. Generics give a third way: writing the structure with a type parameter and supplying the type at the point of use.

Type-Parameterized Structures

A type parameter is written in angle brackets after the declaration’s name and used like a type throughout the body:

interface Measurement {
  id: string;
  value: number;
}

class Log<T> {
  private records: T[] = [];

  add(record: T): void {
    this.records.push(record);
  }

  last(): T | undefined {
    return this.records.at(-1);
  }

  get count(): number {
    return this.records.length;
  }
}

const measurements = new Log<Measurement>();
measurements.add({ id: "s-01", value: 21.4 });
console.log(measurements.last()?.value, measurements.count);

const warnings = new Log<string>();
warnings.add("sensor not responding");
console.log(warnings.last()?.toUpperCase());

Output:

21.4 1
SENSOR NOT RESPONDING

A single class was used with two different record types, and type information was kept at every use: the call measurements.last() gives Measurement | undefined, the call warnings.last() gives string | undefined. If the line measurements.add("s-02"); is added at the end of the file, after a blank line:

m1.ts(30,18): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Measurement'.

An any[] solution would not have produced this diagnostic. Generics are the way to share a common structure without losing type safety.

The return type being T | undefined is also part of the design: an at(-1) call on an empty array gives undefined, and the type records that. The caller is forced to handle that possibility with ?..

Type Argument Inference

The type argument is often left unwritten; the compiler infers it from the arguments:

function first<T>(records: readonly T[]): T | undefined {
  return records[0];
}

const n = first([21.4, 22.1]);
const s = first(["s-01", "s-02"]);
console.log(n?.toFixed(1), s?.toUpperCase());

The output is 21.4 S-01.

There is no need to write first<number> or first<string>. The declared type of n is inferred as number | undefined, the type of s as string | undefined, and each one’s methods are available accordingly.

For inference to work, the type parameter must appear in the arguments. A type parameter that appears only in the return type cannot be inferred; the danger of this case is covered at the end of the lesson.

Constraints

A type parameter is a type about which nothing is known; no operation can be performed on it. To access specific members, a constraint is written on the parameter:

interface Identified {
  id: string;
}

function collectIds<T extends Identified>(records: readonly T[]): string[] {
  return records.map((r) => r.id);
}

console.log(collectIds([{ id: "s-01", value: 21.4 }]));
console.log(collectIds([{ name: "boiler-2" }]));
m4.ts(10,27): error TS2353: Object literal may only specify known properties, and 'name' does not exist in type 'Identified'.

The declaration T extends Identified does two things: it makes the r.id access in the body possible, and it rejects non-conforming arguments at the call site. The acceptance of the ninth line also matters — the value field is extra, and a constraint only states the minimum requirement.

Without the constraint, writing r.id would have been rejected by the compiler: T could be any type, a string as much as a number.

Constraining with keyof

Constraints can also refer to other type parameters. The most useful form is built with the keyof operator, which gives a type’s field names:

interface Measurement {
  id: string;
  value: number;
  unit: "C" | "Pa" | "%";
}

function field<T, A extends keyof T>(record: T, name: A): T[A] {
  return record[name];
}

const record: Measurement = { id: "s-01", value: 21.4, unit: "C" };
const v: number = field(record, "value");
const u: "C" | "Pa" | "%" = field(record, "unit");
console.log(v, u);

field(record, "location");
m5.ts(16,15): error TS2345: Argument of type '"location"' is not assignable to parameter of type 'keyof Measurement'.

Three new notations appear here. keyof T is a union made of literal types of T’s field names — here "id" | "value" | "unit". The constraint A extends keyof T limits the second argument to those names. T[A] is an indexed access type: it gives the type of the field named A.

The result is a return type that varies by field name. The call field(record, "value") has type number, the call field(record, "unit") has type "C" | "Pa" | "%". Without writing an overload, as many distinct signatures as there are fields have been obtained.

Default Type Parameters

A type parameter can be given a default value. A type carrying the result of a boundary validation is a typical use of this:

type Result<D, E = string> =
  | { status: "success"; value: D }
  | { status: "error"; error: E };

interface Measurement {
  id: string;
  value: number;
}

function parse(raw: string): Result<Measurement> {
  const parts = raw.split("=");
  if (parts.length !== 2) {
    return { status: "error", error: `invalid format: ${raw}` };
  }
  const value = Number(parts[1]);
  if (!Number.isFinite(value)) {
    return { status: "error", error: `not a number: ${parts[1]}` };
  }
  return { status: "success", value: { id: parts[0], value } };
}

function format(result: Result<Measurement>): string {
  return result.status === "success"
    ? `${result.value.id}=${result.value.value}`
    : `error: ${result.error}`;
}

console.log(format(parse("s-01=21.4")));
console.log(format(parse("broken")));
console.log(format(parse("s-02=abc")));

Output:

s-01=21.4
error: invalid format: broken
error: not a number: abc

In Result<Measurement>, the second type argument is not given; the default string is used. When the error type needs detail, Result<Measurement, ErrorCode> is written and the code outside format does not change.

This type is the matured form of the boundary rule set in the first lesson. The parse function neither throws an exception nor returns null; it carries failure as a member of the type, and the compiler forces the caller to handle it. The discriminated union has turned into an error-handling tool here.

Misusing Generics

Generics do not improve every situation. There are two counter-examples.

A type parameter that appears only once. In the signature function print<T>(value: T): void, T appears in only one parameter and forms no relationship; writing unknown does the same job more explicitly. The value of a generic lies in forming a link between two points: an input type and an output type, or two parameters.

A type parameter that appears only in the return type. This is a hidden type assertion:

function unsafe<T>(raw: unknown): T {
  return raw as T;
}

const label = unsafe<string>(42);
console.log(label.toUpperCase());

The file passes the type check; when run, however:

TypeError: label.toUpperCase is not a function

Whatever the caller writes for T, the compiler accepts it, because the as T in the body silences the check. This signature looks like it promises validation while validating nothing — it is more dangerous than returning any, because it looks type safe.

Rule: a type parameter must appear in at least two places. If it appears only in the return type, the signature should return unknown and the caller should narrow it themselves.

Summary

  • A type parameter lets a structure’s shape be written independently of the record type, and unlike an any solution, it preserves type safety.
  • Type arguments are usually inferred from call arguments; inference requires the parameter to appear in the argument list.
  • A constraint (T extends K) allows operations on a type parameter and rejects non-conforming arguments at the call site.
  • keyof T gives the union of field names, T[A] the indexed access type; together they build return types that vary by field name.
  • A default type parameter shortens the common case; a result type built with a discriminated union carries failure as a member of the type.
  • A type parameter must appear in at least two places; a parameter appearing only in the return type is a hidden type assertion.

Next Step

Several times in this lesson, a test was performed on a union type and the compiler narrowed it down to a single member. The rules of this narrowing have not yet been laid out: which tests count as proof, how far the proof carries, how the result of an assertion function you write yourself is communicated to the compiler. The next lesson covers narrowing in detail.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close