open atlas
↑ Back to track
TypeScript type system, deep TS · 03 · 04

Generic classes

A class type parameter is scoped to the instance, never the static side. Typed containers like Stack<T> and Result<T, E> show why — and structural compatibility of instances foreshadows variance.

TS Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

You add a static empty(): Box<T> factory to your Box<T> class and the compiler refuses: Static members cannot reference class type parameters. It feels arbitrary — the class is generic, why can’t its static helper be? The answer reveals exactly what a class type parameter is and when it gets pinned to a concrete type.

A class parameterised by a type

Why put a type parameter on a class rather than on each method individually? Because you want every method on the instance to share the same resolved T — so a Box<number> can only ever map numbers, no matter which method you call. A generic class declares a type parameter that each instance fills in:

class Box<T> {
  constructor(public value: T) {}
  map<U>(fn: (v: T) => U): Box<U> {
    return new Box(fn(this.value));
  }
}

const b = new Box(42);
//    ^? const b: Box<number>   — T inferred from the constructor argument
const s = b.map((n) => n.toFixed(2));
//    ^? const s: Box<string>   — map's own U inferred from the callback

T is solved from the constructor argument, just like a generic function infers from its call arguments. The method map<U> introduces its own type parameter U, independent of the instance’s T.

The static-side restriction

Each instance binds T to a concrete type. But static members belong to the class object, which exists once, before and across all instances — there is no single T for them to refer to:

class Box<T> {
  constructor(public value: T) {}

  // Error: Static members cannot reference class type parameters.
  static empty(): Box<T> {
    return new Box(undefined as any);
  }
}

The fix is to make the static itself generic — its own type parameter, resolved at the static call:

class Box<T> {
  constructor(public value: T) {}
  static of<U>(value: U): Box<U> {
    return new Box(value);
  }
}

const b = Box.of("hi");
//    ^? const b: Box<string>

Box.of<U> has nothing to do with any instance’s T; it is solved at the static call site. This is why factory methods are written as standalone generics.

A real container: Result<T, E>

Two type parameters model success-or-failure without exceptions:

type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

function parsePort(s: string): Result<number, string> {
  const n = Number(s);
  return Number.isInteger(n) && n > 0 ? ok(n) : err(`bad port: ${s}`);
}

const r = parsePort("8080");
if (r.ok) {
  r.value;
//  ^? (property) value: number   — narrowed by the discriminant
} else {
  r.error;
//  ^? (property) error: string
}

ok returns Result<T, never> and err returns Result<never, E> so the unused side is never — assignable to any expected error or value type. The discriminant ok then narrows the union (that was the discriminated-unions lesson) to expose exactly one branch’s payload.

Generic interfaces for factories

Interfaces carry type parameters too, which is how you type a factory or a collection contract independent of any implementation:

interface Repository<T, Id = string> {
  find(id: Id): T | undefined;
  save(entity: T): void;
}

interface Comparator<T> {
  (a: T, b: T): number;
}

const byLength: Comparator<string> = (a, b) => a.length - b.length;
//    ^? const byLength: Comparator<string>

Repository<T, Id = string> shows defaults working on interfaces (most ids are strings); Comparator<T> is a generic call signature — a function type parameterised by T.

Structural compatibility foreshadows variance

Two generic instances are compatible when their contents are compatible — TypeScript is structural, so Box<T> is just “an object with a value: T”:

class Box<T> {
  constructor(public value: T) {}
}

let animals: Box<{ name: string }> = new Box({ name: "Rex", legs: 4 });
//                                            ^ extra prop ok via inference widening
let named: Box<{ name: string }> = new Box({ name: "Rex" });
animals = named; // ok — same T

let dogBox: Box<{ name: string; legs: number }> = new Box({ name: "Rex", legs: 4 });
named = dogBox;  // ok — Box<{name; legs}> assignable to Box<{name}>?  Yes here.
// dogBox = named; // Error: missing 'legs' — narrower target, wider source

Whether Box<Dog> is assignable to Box<Animal> depends on how T is used inside the class — read-only positions behave one way, write positions another. That direction-of-assignability question is variance, and it is the entire next lesson.

What generic containers cost the declaration files

Generics are erased from the emitted JSclass Box<T> compiles to a plain class Box with zero runtime trace of T. But they are very much present in the declaration output. A non-generic container emits a flat .d.ts entry; a deeply parameterised one — Result<T, E>, a Repository<T, Id>, a builder threading three type parameters through a dozen methods — emits every method signature with its full generic shape, and library authors regularly watch a public API’s .d.ts grow from a few kilobytes to tens of kilobytes once the container types get rich. That bundle ships to every consumer and every consumer’s tsc re-checks it, so over-parameterised public types are a tax paid by everyone downstream, not just the author.

There is an editor-latency angle too. Each new Box(...) and each .map(...) call re-runs inference for that instance’s T (and the method’s own U); a screen of chained container operations is a screen of independent inference problems the language server solves on every keystroke. One Result<T, E> is free; a hot path built from twenty stacked generic container calls is where hover and autocomplete start lagging, and the --extendedDiagnostics Check time for that file climbs out of proportion to its line count.

Why this works

Why is the static restriction not just a missing feature? At runtime there is exactly one Box constructor function. Generics are erased — they exist only for the checker. An instance’s T is a fiction the checker maintains per-instance from the constructor call; but a static method is shared by every instance regardless of their T, so there is no coherent value for T to take in static scope. Making the static method generic restores coherence: it gets a fresh type parameter solved at its own call site, unrelated to any instance.

The tradeoff a senior weighs with a generic container is reuse against the declaration weight and inference cost it imposes on every consumer. A Result<T, E> or Stack<T> that genuinely carries its payload type end to end earns those costs — it removes casts at every call site. But a container parameterised “in case we need it” — a third type variable no method actually relates, or a Repository<T, Id, Meta> where Meta is touched in one place — pays the full .d.ts and inference price while delivering none of the safety. The discipline mirrors the function rule from lesson one: a class type parameter must relate positions across the container’s surface; the moment one is decorative, drop it and let the container be concrete, because here the cost is borne by everyone who imports it.

Quiz

Why does `static empty(): Box<T> { ... }` inside `class Box<T>` fail to compile?

Quiz

In `function ok<T>(value: T): Result<T, never>`, why is the error slot typed `never`?

Order the steps

Order how the checker types `new Box(42).map(n => n.toFixed(2))` for `class Box<T> { constructor(public value: T) {} map<U>(fn: (v: T) => U): Box<U> }`:

  1. 1 Infer the instance's T = number from the constructor argument 42
  2. 2 Type the instance as Box<number>
  3. 3 Enter map, whose parameter fn is (v: number) => U
  4. 4 Infer map's own U = string from the callback's return n.toFixed(2)
  5. 5 Report the result as Box<string>
Complete the analogy

Fill in the blank: a class type parameter is scoped to the _______, which is why the static side — shared across all of them — cannot reference it.

Recall before you leave
  1. 01
    Why can a static member not reference a class type parameter, and what is the standard fix?
  2. 02
    Explain how `Result<T, E>` plus `ok`/`err` constructors give exception-free error handling, including the role of `never`.
Recap

Generic classes and interfaces parameterise instances over a type the constructor (or factory) supplies, while methods may add their own independent type parameters. The static side cannot reference a class type parameter — statics exist before any instance binds it — so factories are written as standalone generics. Typed containers like Stack<T> and Result<T, E> are the everyday payoff, and interface generics with defaults model repositories and comparators. Whether Box<Dog> may stand in for Box<Animal> depends on how T is used inside the class; that direction-of-assignability question is variance — the subject, and the real-codebase hazard, of the next and final lesson in this unit. Now when you hit Static members cannot reference class type parameters, you know the fix immediately: give the static its own type parameter, resolved at its own call site.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources1
expand
  1. 01

Trademarks belong to their respective owners. Editorial reference only.