Skip to content

Generic Classes & Interfaces

An interface or type alias can take type parameters exactly like a function. The classic is a typed container:

interface Box<T> {
value: T;
}
const numBox: Box<number> = { value: 42 };
const strBox: Box<string> = { value: "hi" };
numBox.value.toFixed(2); // ✅ value is number

Type aliases work the same way and are often better for unions and functions:

type Pair<A, B> = { first: A; second: B };
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
const r: Result<number> = { ok: true, value: 10 };

Result<T> is a generic discriminated union — one of the most useful patterns in TypeScript, and something we return to in the error-handling lesson.

Generic classes: the parameter is fixed per instance

Section titled “Generic classes: the parameter is fixed per instance”

When a class is generic, its type parameter is chosen when you construct an instance, and every method of that instance shares it:

// @expect-error TS2345
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
}
const numbers = new Stack<number>();
numbers.push(1);
numbers.push("two"); // ❌ Argument of type 'string' is not assignable to 'number'
const last = numbers.pop();
// ^? number | undefined

(last, not top — a script-scope file shares globals with the DOM lib, and top already means Window.top; an import/export module wouldn’t collide.)

Once you write new Stack<number>(), T is number for the life of that object. Often you don’t even write it — new Stack() with a first push(1) can’t infer, but many designs let inference fill T from a constructor argument.

unknown, not any, when there is nothing to infer from

Section titled “unknown, not any, when there is nothing to infer from”

When nothing lets T infer at all, T doesn’t become an error and it doesn’t become any either — it becomes unknown. new Stack() alone gives you a Stack<unknown>:

class Box<T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
}
const b = new Box();
// ^? Box<unknown>
b.push(1);
b.push("two"); // ✅ also compiles — unknown accepts any value in parameter position

Both pushes above type-check, and that’s easy to misread as T having quietly become any. It hasn’t — the difference matters everywhere else b is used. unknown in a parameter position accepts anything, which is why push never complains no matter what you hand it; but unknown in a position you read from still forces you to narrow before you can do anything with it, the same as any other unknown value. any would have let you do both — write anything in, and read the result back out as any type you like, no narrowing required. unknown only gives you the first half:

// @expect-error TS18046
class Box<T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
const b = new Box();
b.push(1);
const x = b.pop();
// ^? unknown | undefined
x.toFixed(0); // ❌ 'x' is of type 'unknown' — you must narrow it first, unlike any

That’s the real fix implied by the earlier example: don’t leave T for the compiler to default — write new Stack<number>(), or design the constructor so an argument lets inference fill it in. unknown is the compiler being honest about “I genuinely don’t know,” not a silent downgrade to “anything goes.”

A type parameter declared on a method (not the class) is chosen fresh on each call, independent of the class parameter:

class Collection<T> {
constructor(private items: T[]) {}
// T is the class parameter (fixed per instance)
first(): T | undefined {
return this.items[0];
}
// U is a method parameter (fresh per call)
map<U>(fn: (item: T) => U): U[] {
return this.items.map(fn);
}
}
const c = new Collection([1, 2, 3]); // T = number
const lengths = c.map((n) => n.toString().length);
// ^? number[] — U inferred as number, per this call
const labels = c.map((n) => `item-${n}`);
// ^? string[] — U inferred as string, a different call

T is decided once (at construction); U is decided every time you call map. Knowing which parameter belongs to the class and which to the method is the key to reading generic class signatures.

A class can implement a generic interface either by fixing the parameter or by staying generic itself:

interface Repository<T> {
getById(id: string): T | undefined;
save(entity: T): void;
}
interface User {
id: string;
name: string;
}
// fix the parameter to a concrete type
class UserRepository implements Repository<User> {
getById(id: string): User | undefined { /* ... */ return undefined; }
save(entity: User): void { /* ... */ }
}

An AI smell: type parameter fixed at the wrong scope

Section titled “An AI smell: type parameter fixed at the wrong scope”

Now that you know class parameters are per-instance and method parameters are per-call, there’s a specific mistake generated code makes surprisingly often: putting the type parameter on the wrong one of the two.

Smell 1 — should be a method parameter, declared on the class instead. A T/U pair on the class forces every use of an instance to agree on the same types, even when each call was really meant to be independent:

// @expect-error TS2322 TS2339
class MapperWrong<T, U> {
transform(items: T[], fn: (item: T) => U): U[] {
return items.map(fn);
}
}
const m = new MapperWrong<number, string>();
m.transform([1, 2, 3], (n) => n.toString());
m.transform(["a", "b"], (s) => s.length); // ❌ T is locked to number for this instance

Once m is constructed as MapperWrong<number, string>, every later .transform call is stuck with T = number, even though nothing about “transform this array with this function” needs to remember a type across calls. The fix is to move T and U onto the method, where they belong:

class Mapper {
transform<T, U>(items: T[], fn: (item: T) => U): U[] {
return items.map(fn);
}
}
const m = new Mapper();
m.transform([1, 2, 3], (n) => n.toString()); // T = number, U = string, this call only
m.transform(["a", "b"], (s) => s.length); // ✅ T = string, U = number, independent call

Smell 2 — should be a class parameter, declared on the method instead. This direction is sneakier, because it often still compiles and still “looks” generic:

class StackWrong {
private items: any[] = [];
push<U>(item: U): void { this.items.push(item); }
pop<U>(): U | undefined { return this.items.pop(); }
}
const sw = new StackWrong();
sw.push("first");
sw.push(42); // compiles — push<U> is a fresh, disconnected U every call
const claimed = sw.pop<string>(); // claims string — but the real top item is 42
console.log(claimed!.toUpperCase()); // "trust me" — U isn't linked to what was actually pushed
// @run
class StackWrong {
private items: any[] = [];
push<U>(item: U): void { this.items.push(item); }
pop<U>(): U | undefined { return this.items.pop(); }
}
const sw = new StackWrong();
sw.push("first");
sw.push(42);
const claimed = sw.pop<string>();
console.log(claimed!.toUpperCase());

This compiles with zero errors, and running it crashes:

TypeError: claimed.toUpperCase is not a function

Here items is typed any[] internally, so it never actually shares a type with push’s or pop’s per-call U — each U is decorative, exactly like the “used once, links nothing” smell from the previous lesson, just spread across two methods that superficially look linked because they share a letter. The real Stack<T> from the top of this lesson is the fix: one class-level T, shared by push and pop, actually tying what goes in to what can come out.

Variance: does Stack<Dog> fit where Stack<Animal> is expected?

Section titled “Variance: does Stack<Dog> fit where Stack<Animal> is expected?”

Once a type parameter is fixed per instance, a new question appears that never comes up for a plain function: if Dog is a subtype of Animal, is Stack<Dog> a subtype of Stack<Animal>? This question — variance — has a different answer depending on how the type parameter is used inside the generic type, and TypeScript’s answer is, in one specific case, unsound on purpose.

Assign a Stack<Dog> to a variable typed Stack<Animal>, using the ordinary method-shorthand push(item: T): void from this lesson’s Stack:

class Animal { name = "animal"; }
class Dog extends Animal { bark() { return "woof"; } }
class Cat extends Animal { meow() { return "meow"; } }
class StackMethod<T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
const dogs = new StackMethod<Dog>();
const animals: StackMethod<Animal> = dogs; // allowed — no error at all
animals.push(new Cat()); // pushes a Cat into what is really the Dog stack

This compiles with zero errors. animals is typed StackMethod<Animal>, so pushing a Cat (an Animal) looks completely fine — but animals and dogs are the same object. Pop from the real Dog stack and you get the Cat back, typed as Dog:

// @run
class Animal { name = "animal"; }
class Dog extends Animal { bark() { return "woof"; } }
class Cat extends Animal { meow() { return "meow"; } }
class StackMethod<T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
const dogs = new StackMethod<Dog>();
const animals: StackMethod<Animal> = dogs;
animals.push(new Cat());
const back = dogs.pop();
console.log(back!.bark());
TypeError: back.bark is not a function

This is real, known unsoundness, not a bug you triggered by accident: methods written with shorthand syntax (push(item: T): void) are checked bivariantly — TypeScript allows the assignment in either direction, Stack<Dog> to Stack<Animal> or the reverse, because enforcing strict contravariance here broke enough real-world code (especially callback-heavy APIs) when it was tried. strictFunctionTypes — the flag that tightens this exact check — was deliberately scoped to skip method shorthand for this reason.

Property function syntax: correctly rejected

Section titled “Property function syntax: correctly rejected”

Write the same method as a property whose type is a function instead of shorthand, and the same assignment is rejected:

// @expect-error TS2322
class Animal { name = "animal"; }
class Dog extends Animal { bark() { return "woof"; } }
class Cat extends Animal { meow() { return "meow"; } }
class StackProp<T> {
private items: T[] = [];
push: (item: T) => void = (item) => { this.items.push(item); };
pop(): T | undefined { return this.items.pop(); }
}
const dogs = new StackProp<Dog>();
const animals: StackProp<Animal> = dogs;
animals.push(new Cat());

The error names exactly the mismatch a sound type system should catch:

error TS2322: Type 'StackProp<Dog>' is not assignable to type 'StackProp<Animal>'.
Types of property 'push' are incompatible.
Type '(item: Dog) => void' is not assignable to type '(item: Animal) => void'.
Types of parameters 'item' and 'item' are incompatible.
Property 'bark' is missing in type 'Animal' but required in type 'Dog'.

Same class shape, same intent, two different outcomes — purely because of how the method was spelled. This is one of the sharpest “reading TypeScript” lessons in the language: a method written with shorthand syntax on a class or interface is checked more permissively than the exact same signature written as a property.

in and out: TypeScript does have variance annotations

Section titled “in and out: TypeScript does have variance annotations”

Unlike some type systems that only reason about variance implicitly, TypeScript (4.7+) lets you declare it, and checks your declaration against how the type parameter is actually used:

// @expect-error TS2636
interface Consumer<out T> {
consume: (value: T) => void; // T is only ever consumed -- this is really contravariant
}
error TS2636: Type 'Consumer<sub-T>' is not assignable to type 'Consumer<super-T>' as implied by variance annotation.
Types of property 'consume' are incompatible.
Type '(value: sub-T) => void' is not assignable to type '(value: super-T) => void'.
Types of parameters 'value' and 'value' are incompatible.
Type 'super-T' is not assignable to type 'sub-T'.

out T means “T only appears in output positions” (covariant — the same direction as the type argument: Dog in, Dog out means Producer<Dog> fits where Producer<Animal> is expected). in T means “T only appears in input positions” (contravariant — the opposite direction). in out T means both, i.e. invariant — no safe subtyping either way. You’ll meet this same vocabulary in other languages’ own variance systems: Kotlin and C# use the identical in/out keywords; Java spells it with wildcards (? extends T for covariant, ? super T for contravariant); Flow uses +/- prefixes. TypeScript’s version is unusual in one way worth naming explicitly: the annotations are optional, and mainly a documentation-and-performance aid for library authors — TypeScript already computes variance structurally whether you annotate or not, and the annotation is checked as an assertion (that’s what produced TS2636 above) rather than a request that changes what the checker enforces elsewhere.

That last point matters: annotating Stack<out T> doesn’t change whether Stack<Dog> fits where Stack<Animal> is expected — the structural rules from this section already decided that before you wrote a single in/out. And because the annotation is checked using the same variance measurement as ordinary assignability, it inherits the exact same method-shorthand blind spot:

// declaring `out T` on a class whose push is method shorthand -- compiles clean,
// even though push(item: T) is genuinely used contravariantly. The bivariance
// that lets Stack<Dog> assign to Stack<Animal> also fools the annotation checker.
class StackOut<out T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}

Change push to property-function syntax on the same declaration and TS2636 fires immediately, for the identical reason the assignment itself gets rejected in the previous section. in/out in TypeScript, in other words, is a real feature you will meet in error messages and in library .d.ts files — but it documents and double-checks the same variance the compiler was already computing, it doesn’t add a new enforcement mechanism on top of it.

An array you can only read from is safe to treat covariantly, and TypeScript allows exactly that:

class Animal { name = "animal"; }
class Dog extends Animal { bark() { return "woof"; } }
declare const dogs: readonly Dog[];
const animals: readonly Animal[] = dogs; // ✅ covariant — readonly arrays allow this safely

There’s no push to smuggle a Cat through, so there’s no unsoundness here — every Dog really is a valid Animal to read.

The same thing, but mutable: known, deliberate unsoundness

Section titled “The same thing, but mutable: known, deliberate unsoundness”

Drop readonly and the identical assignment is still allowed — arrays are covariant in TypeScript even when mutable, which is well-known, long-standing unsoundness kept for JavaScript-code compatibility reasons:

class Animal { name = "animal"; }
class Dog extends Animal { bark() { return "woof"; } }
class Cat extends Animal { meow() { return "meow"; } }
declare const dogs: Dog[];
const animals: Animal[] = dogs; // compiles — mutable arrays are covariant too
animals.push(new Cat()); // pushes a Cat into the array dogs also points at
// @run
class Animal { name = "animal"; }
class Dog extends Animal { bark() { return "woof"; } }
class Cat extends Animal { meow() { return "meow"; } }
declare const dogsSrc: Dog[];
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs;
animals.push(new Cat());
console.log(dogs[dogs.length - 1].bark());
TypeError: dogs[dogs.length - 1].bark is not a function

A standalone function type (not a method) is checked contravariantly in parameter position under strictFunctionTypes — the opposite direction from the type argument, and this direction is sound. A callback that accepts a wider type than required is safe to use where a narrower one is expected, because it can still handle every value that arrives:

class Animal { name = "animal"; }
class Dog extends Animal { bark() { return "woof"; } }
declare function forEachDog(cb: (d: Dog) => void): void;
const handleAnimal = (a: Animal) => console.log(a.name);
forEachDog(handleAnimal); // ✅ a callback that handles ANY Animal can certainly handle a Dog

The reverse fails, correctly — a callback that only knows how to handle Dog can’t be trusted with every Animal:

// @expect-error TS2345
class Animal { name = "animal"; }
class Dog extends Animal { bark() { return "woof"; } }
declare function forEachAnimal(cb: (a: Animal) => void): void;
const handleDog = (d: Dog) => console.log(d.bark());
forEachAnimal(handleDog); // ❌ handleDog can't handle every Animal (e.g. a Cat)
error TS2345: Argument of type '(d: Dog) => void' is not assignable to parameter of type '(a: Animal) => void'.
Types of parameters 'd' and 'a' are incompatible.
Property 'bark' is missing in type 'Animal' but required in type 'Dog'.

Invariance: mutable containers should reject both directions — TypeScript doesn’t enforce it

Section titled “Invariance: mutable containers should reject both directions — TypeScript doesn’t enforce it”

In a type system with real invariance (Java’s List<T>, for instance), a mutable container’s type parameter can’t safely vary in either direction: reading requires covariance, writing requires contravariance, and a container that does both needs both, which only invariance — no safe substitution either way — actually guarantees. TypeScript has no invariant enforcement for an ordinary mutable field; it checks object properties structurally and covariantly, the same unsoundness you just saw for mutable arrays, and it corrupts state through the write side exactly the same way:

class Animal { name = "animal"; }
class Dog extends Animal { bark() { return "woof"; } }
class Cat extends Animal { meow() { return "meow"; } }
class Box<T> {
value: T;
constructor(value: T) { this.value = value; }
}
const dogBox = new Box<Dog>(new Dog());
const animalBox: Box<Animal> = dogBox; // TS allows this — Box<T> is not enforced invariant
animalBox.value = new Cat(); // writes THROUGH to the same object dogBox holds

Invariance TypeScript doesn't enforce: a write-through corruption

class Animal { name = "animal"; }
class Dog extends Animal { bark() { return "woof"; } }
class Cat extends Animal { meow() { return "meow"; } }
class Box<T> {
value: T;
constructor(value: T) { this.value = value; }
}
const dogBox = new Box<Dog>(new Dog());
const animalBox: Box<Animal> = dogBox; // TS allows this -- Box<T> is not enforced invariant
animalBox.value = new Cat(); // writes through the SAME object dogBox holds
console.log(dogBox.value.bark()); // dogBox.value is now a Cat -- crash
Open in TS Playground

Click Check types: zero diagnostics. Click Run: it crashes with dogBox.value.bark is not a function, because animalBox.value = new Cat() really did overwrite the Dog that dogBox was holding — dogBox and animalBox are the same object in memory, and the assignment went through the wider, permissive type. A language with real invariance for mutable generics (Java, Kotlin’s Array<T>) rejects the dogBox-to-animalBox assignment outright, at the first line. TypeScript’s structural, property-by-property covariant check doesn’t have a concept of “this field is written to, so treat it invariantly” — it just checks whether Dog is assignable to Animal for that property and calls it done.

Ask three questions about any generic type you’re reading, in order: (1) Is the type parameter fixed per instance (class-level) or fresh per call (method-level)? (2) For each position T appears in, is it something you read (covariant, safe to widen), something you write (contravariant for a standalone function parameter, but bivariant and unsound for a method-shorthand parameter), or both (invariant — but TypeScript rarely actually enforces this for a plain mutable field)? (3) If two variables of the same generic type but different type arguments end up aliasing the same object — through exactly the unsound assignments this lesson demonstrated — what’s the worst thing one of them can do to the other?

DesignBenefitCost
Class-level type parameterOne type shared correctly across every method of an instanceWrong choice if different calls genuinely need independent types — see Smell 1
Method-level type parameterIndependent per call, no cross-call couplingWrong choice if the type needs to persist across calls on the same instance — see Smell 2 (decorative, unsound)
Method shorthand (f(x: T): void)Concise, and needed for normal OOP method overriding to work at allBivariant — unsound for mutating generic containers, as this lesson’s StackMethod showed
Property function (f: (x: T) => void)Correctly contravariant under strictFunctionTypes — catches real bugsMore verbose; loses some OOP override ergonomics
readonly T[] / in/out annotationsDocuments and enforces the safe direction explicitlyDoesn’t retroactively fix a design that needs to both read and write unsoundly
  • unknown in a generic that couldn’t infer means the class is now unsafe, just like any. It’s unknown, not any — writing into it (parameter positions) is unrestricted, but reading it back out still requires narrowing before use, unlike any.
  • A generic class’s variance is one fixed property of the class. It depends on how each member uses the type parameter, position by position, and — for methods — on whether that member is written with shorthand or property-function syntax. The exact same Stack<T> shape can be sound or unsound in the same file depending on which syntax you pick for push.
  • TypeScript’s in/out annotations make a generic type safe. They document and assert a variance the checker already computes, and are checked against the measured variance — which itself inherits method-shorthand bivariance. Annotating out T on a class whose push is method shorthand compiles clean even though the underlying unsoundness is still there.
  • Mutable generic containers in TypeScript are invariant, the way they are in Java. They’re structurally covariant like everything else in TypeScript’s object model — nothing stops you from assigning Box<Dog> to Box<Animal> and corrupting the original through the write side.

Two questions catch most generated-code mistakes with type parameters:

  • Is the type parameter on the right declaration? If every call to a method needs its own, independent type, and the class declares the parameter instead, look for the class’s type argument being “locked in” awkwardly at construction for something that should vary call to call.
  • Is a per-method type parameter actually connected to anything the instance stores? If a class stores its data as any[] (or another untyped/widened internal representation) while its methods declare their own <U>, the type parameter is decorative — it lets the caller claim a type on the way out with nothing checking the claim against what’s really stored.

🐛 Spot the bug

class Cache {
private store: any[] = [];
set<T>(value: T): number {
return this.store.push(value) - 1;
}
get<T>(index: number): T {
return this.store[index];
}
}
const cache = new Cache();
const id = cache.set({ name: "Ada" });
const later = cache.get<string[]>(id);
console.log(later.join(", "));
Show the answer

This compiles with zero errors. Cache stores everything in store: any[], so set<T> and get<T> each declare their own, completely independent T per call — nothing links what set stored to what get is allowed to claim back. cache.set({ name: "Ada" }) stores an object; cache.get<string[]>(id) then asserts the caller knows the stored value is a string[], and the compiler has no way to check that claim against the real stored any. later.join(", ") type-checks because string[] has .join, but at runtime later is { name: "Ada" }, so it crashes with later.join is not a function. The type parameter should have been on the class (Cache<T>), fixed once per instance, so set and get are forced to agree on the same T — that’s the Stack<T> shape from earlier in this lesson, and it’s exactly what per-call type parameters on a shared, untyped store can never provide.

For class Stack<T>, when is the type parameter T decided?
In a class Collection<T> with a method map<U>(...), how do T and U differ?
new Box() with no type argument and nothing else to infer from produces a Box<what>, and what does that mean for a later .push(1)?
Does this compile, and what happens when it runs?
class StackWrong {
  private items: any[] = [];
  push<U>(item: U): void { this.items.push(item); }
  pop<U>(): U | undefined { return this.items.pop(); }
}
const sw = new StackWrong();
sw.push("first");
sw.push(42);
const claimed = sw.pop<string>();
claimed!.toUpperCase();
Assigning Stack<Dog> to a variable typed Stack<Animal> and then calling .push(new Cat()) on it — does this compile, and does it depend on how push is written?
TypeScript lets you write interface Producer<out T>. What does the out annotation actually do?