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.
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 callbackT 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 sourceWhether 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 JS — class 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.
Why does `static empty(): Box<T> { ... }` inside `class Box<T>` fail to compile?
In `function ok<T>(value: T): Result<T, never>`, why is the error slot typed `never`?
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 Infer the instance's T = number from the constructor argument 42
- 2 Type the instance as Box<number>
- 3 Enter map, whose parameter fn is (v: number) => U
- 4 Infer map's own U = string from the callback's return n.toFixed(2)
- 5 Report the result as Box<string>
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.
- 01Why can a static member not reference a class type parameter, and what is the standard fix?
- 02Explain how `Result<T, E>` plus `ok`/`err` constructors give exception-free error handling, including the role of `never`.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.