Classes & Advanced OOP
Classes live in both worlds
Section titled “Classes live in both worlds”Almost everything else in this course is a type — a compile-time construct that gets erased. A class is different: it is one of the rare declarations that creates both a type and a value. The class name is a type you can annotate with, and simultaneously a runtime constructor you can call with new.
class User { name: string; constructor(name: string) { this.name = name; }}
const u: User = new User("Ada"); // `User` as a TYPE (annotation) and a VALUE (constructor)That dual nature is why classes feel special, and why their features split into two groups: ones the compiler checks and erases (like private), and ones that survive into the emitted JavaScript (like #private fields).
What this module covers
Section titled “What this module covers”| Lesson | What you’ll learn |
|---|---|
| Classes, deep | Access modifiers, readonly, parameter properties, static, abstract, implements |
this types & polymorphism | Polymorphic this, this parameters, and binding pitfalls in the type system |
| Decorators | The modern stage-3 decorators — class, method, field — and factories |
| Mixins & composition | Building behavior from composable class factories instead of deep hierarchies |
A word on inheritance
Section titled “A word on inheritance”TypeScript supports classical extends inheritance, but this module leans toward the features the community actually reaches for in modern code: composition over inheritance. Deep class hierarchies are brittle; this types, decorators, and mixins let you build flexible, reusable behavior without them.
flowchart TB cls["class declaration"] --> typeworld["TYPE world (erased): private, protected, implements, abstract"] cls --> valueworld["VALUE world (runtime): #private, static, the constructor, methods"]
Structural, until private gets involved
Section titled “Structural, until private gets involved”Every other type in this course — object types, interfaces, function types — is compared structurally: two types are compatible if their shapes match, no matter what they’re named or where they’re declared. Classes follow the same rule for their public members. But add a private (or protected) member, and a class stops being purely structural — it becomes nominal-ish: two classes with an identical shape, including a same-named private field, are still not assignable to each other.
// @expect-error TS2345class ApiClient { private token: string; constructor(token: string) { this.token = token; } fetch(path: string) { return `${path}?token=${this.token}`; }}
function useClient(client: ApiClient) { return client.fetch("/users");}
// A test double with the EXACT same shape, field for field, method for method —// structurally this should be a perfect match.class FakeApiClient { private token: string; constructor() { this.token = "fake"; } fetch(path: string) { return `${path}?token=${this.token}`; }}
useClient(new FakeApiClient()); // ❌ still rejectederror TS2345: Argument of type 'FakeApiClient' is not assignable to parameter of type 'ApiClient'. Types have separate declarations of a private property 'token'.Nothing about token’s shape differs between the two classes — same name, same type, same visibility. TypeScript rejects the assignment anyway, because each private member is tagged with the class that declared it, and ApiClient#token and FakeApiClient#token are two different tags even though they look identical on paper. This is the one place TypeScript’s structural model bends toward nominal typing: it exists specifically so that two unrelated classes can’t accidentally satisfy each other’s private contracts just by happening to declare a same-named field.
#private fields (genuine ECMAScript private fields, not just the private keyword) enforce the identical rule, for the identical reason:
// @expect-error TS2345class WalletA { #balance = 0;}class WalletB { #balance = 0;}function drain(w: WalletA) { return w; }drain(new WalletB()); // ❌ different #balance, even though both look the sameerror TS2345: Argument of type 'WalletB' is not assignable to parameter of type 'WalletA'. Property '#balance' in type 'WalletB' refers to a different member that cannot be accessed from within type 'WalletA'.#private as a brand check
Section titled “#private as a brand check”Because a #name field is tied to the exact class that declared it, checking whether that field exists on an unknown object (#name in obj) works as a reliable, forgery-proof brand check — nothing outside the class can ever have added that exact private slot:
A #private field as an unforgeable brand
class Wallet { #balance = 0;
static isWallet(obj: unknown): obj is Wallet { return typeof obj === "object" && obj !== null && #balance in obj; }}
const w = new Wallet();console.log(Wallet.isWallet(w)); // true -- has the real #balance slotconsole.log(Wallet.isWallet({})); // false -- no such slot exists, and none could be faked Click Run: true then false. No amount of duck-typing an object to look like a Wallet — adding a property literally named balance, or any other public field — can make #balance in obj return true; the #-prefixed name isn’t just hidden, it isn’t even a string property key another piece of code could spell. This is the sharpest tool TypeScript (via real ECMAScript private fields) has for turning a structural type into a genuinely nominal one, on purpose.
Mental Model
Section titled “Mental Model”Ask one question when a class assignment you expected to work gets rejected: does either side declare a private or #private member? If not, the ordinary structural rules from every other lesson apply unchanged — shape is everything. If so, TypeScript is deliberately being nominal for that member: it doesn’t matter that the shapes match, only whether both sides trace back to the same declaration. #private takes this further than private — a #name in obj check gives you a runtime-enforced brand that no external code can forge, which is why it is the idiomatic way to answer “is this really an instance of my class?” for security- or identity-sensitive code, not just a type-checking convenience.
Trade-offs
Section titled “Trade-offs”| Feature | Benefit | Cost |
|---|---|---|
| Public members (structural) | Maximum flexibility — any shape-compatible value works, including plain objects and test doubles | No protection against accidental shape collisions between unrelated types |
private member | Documents intent, catches accidental cross-class assignment, compile-time only | Erased at emit — a determined caller reaches it via (obj as any).field; two classes with an identical shape still won’t structurally match |
#private field | Real runtime privacy, unforgeable brand check via #x in obj, survives to the emitted JavaScript | Slightly more ceremony (# syntax), and — like private — makes the class nominal-ish for that member |
Common Misconceptions
Section titled “Common Misconceptions”- “TypeScript is structural, so any two classes with the same shape are interchangeable.” True for public members only. The moment either class declares a
privateor#privatemember with a matching name, that specific member — and therefore the whole class comparison — is checked nominally: same declaration or nothing. privateand#privateprotect a field the same way, just with different syntax.privateis a compile-time-only annotation, erased on emit, readable at runtime viaany.#privateis enforced by the JavaScript engine itself and cannot be bypassed even with a cast — the next lesson proves both halves of that claim with real emitted output.- A test double needs to
implementsor structurally match a class to be usable in its place. If the real class has anyprivate/#privatemember, a hand-written double with the “same” shape will be rejected outright (asFakeApiClientwas above) — the usual fix is to extract aprivate-freeinterfacefor the public contract and depend on that instead of the concrete class.
Reading AI-generated code
Section titled “Reading AI-generated code”Generated test doubles and mocks are a common place this bites: a model asked to “write a fake ApiClient for testing” will often copy the real class’s fields verbatim, private modifiers included, and the result won’t type-check anywhere the real class was expected.
- See a
privateor#field duplicated into an unrelated class “for parity”? Ask whether the two classes are actually meant to be interchangeable. If they are, the fix is almost always to extract aninterfacedescribing only the public contract, and type the consuming code against that interface instead of the concrete class. - See
(obj as any).privateFieldin generated code reading a class’s internals from outside? That’s reaching through the compile-time-only erasure ofprivate— it works, but it defeats the entire point of marking the field private in the first place, and silently breaks if the field is ever renamed.
🐛 Spot the bug
class ApiClient {private token: string;constructor(token: string) { this.token = token;}fetch(path: string) { return `${path}?token=${this.token}`;}}
function useClient(client: ApiClient) {return client.fetch("/users");}
class FakeApiClient {private token: string;constructor() { this.token = "fake";}fetch(path: string) { return `${path}?token=${this.token}`;}}
useClient(new FakeApiClient());Show the answer
This looks like a reasonable hand-rolled test double — same field, same constructor shape, same method — and a model asked to “make a fake ApiClient for tests” produces almost exactly this. It fails to compile: TS2345: Argument of type 'FakeApiClient' is not assignable to parameter of type 'ApiClient'. Types have separate declarations of a private property 'token'. Because ApiClient declares a private token, TypeScript compares it nominally for that member — FakeApiClient’s own private token is a different declaration, even though it looks identical. The fix is to stop depending on the concrete class: extract interface ApiClientLike { fetch(path: string): string } (no private members, since interfaces can’t have them), type useClient against that interface, and have both ApiClient and FakeApiClient satisfy it structurally.