Classes, Deep
Two kinds of private
Section titled “Two kinds of private”TypeScript has two ways to make a member private, and they are not the same.
class Account { private balance = 0; // compile-time only — erased #pin = "1234"; // truly private — enforced at runtime
check() { return this.balance + Number(this.#pin); }}
const a = new Account();// a.balance; // ❌ compile error, but...// (a as any).balance; // ✅ reachable at runtime — private was erased// a.#pin; // ❌ SyntaxError even at runtime — hard privateprivate(andprotected) are compile-time access checks. They are erased on emit, so a determined caller can reach the field at runtime viaanyor bracket access. They document intent and catch honest mistakes.#namefields are ECMAScript private fields — genuinely inaccessible outside the class, enforced by the JavaScript engine itself. Reach for#when privacy must hold at runtime.
protected sits between: accessible in the class and its subclasses, but not from outside.
readonly fields
Section titled “readonly fields”readonly allows assignment only in the declaration or constructor, then locks the field. Like private, it is a compile-time guarantee — erased at runtime.
class Point { readonly x: number; constructor(x: number) { this.x = x; // ✅ allowed in the constructor } move() { // this.x = 5; // ❌ Cannot assign to 'x' because it is read-only. }}Parameter properties: the constructor shorthand
Section titled “Parameter properties: the constructor shorthand”Declaring a field and assigning it from a constructor parameter is so common that TypeScript has a shorthand: put an access modifier on the constructor parameter and it becomes a field automatically.
class User { constructor( public readonly id: string, private name: string, ) {} // No separate field declarations, no `this.id = id` — the modifiers do it.}This is equivalent to declaring id and name as fields and assigning them in the body. It is purely a convenience, but a widely used one.
Parameter properties vs erasable syntax
Section titled “Parameter properties vs erasable syntax”Parameter properties are one of the few TypeScript features that generate code: the compiler has to emit the this.id = id assignments. That matters more than it used to. Node runs .ts files directly by stripping types (Node 22.6+ behind a flag, on by default since Node 23.6 / 24), and TypeScript 5.8 added --erasableSyntaxOnly to enforce that your code only uses syntax that can be erased — so under either, parameter properties are an error, alongside enum, namespaces with runtime code, and import =.
// ❌ under --erasableSyntaxOnly / node file.tsclass UserParamProp { constructor(public readonly id: string) {} // ~~~~~~~~~~~~~~~~~~~~~~~~~~ // This syntax is not allowed when 'erasableSyntaxOnly' is enabled.}
// ✅ the explicit form — same runtime shape, pure erasable syntaxclass UserExplicit { readonly id: string; constructor(id: string) { this.id = id; }}Two related notes. Since TypeScript 6.0, strict is on by default, so strictPropertyInitialization is too: a field declared without an initializer must be definitely assigned in the constructor (or marked id!: string if you assign it elsewhere). And the accessor keyword (5.0) declares an auto-accessor — a getter/setter pair backed by a private slot — which, like stage-3 decorators, is standard ECMAScript syntax and therefore erasable-safe. More on both below.
override and noImplicitOverride
Section titled “override and noImplicitOverride”Writing override on a method tells the compiler — and the next reader — that this member is intentionally replacing one from the base class. That check runs immediately, with no flag required: if the base class doesn’t actually have a member by that name (usually a typo), the error points at the typo instead of silently declaring an unrelated new method.
// @expect-error TS4117class Animal { makeSound() { return "..."; }}
class Dog extends Animal { override makeSound() { return "Woof"; } // ✅ genuinely overrides override mkSound() { return "Woof"; } // ❌ typo — no such member on Animal}That’s always on. What noImplicitOverride adds is the mirror requirement — and it is not part of strict, so you opt in separately ("noImplicitOverride": true). With it enabled, silently overriding a base method without writing override becomes an error too: TS4114: This member must have an 'override' modifier because it overrides a member in the base class 'Animal'. Together the two checks mean an override you didn’t intend is always caught, and (with the flag) one you forgot to label is caught as well. Since 5.6 the override checks also apply to members with computed names such as [sym]() {}.
A base-method rename breaks its overriders — on purpose
Section titled “A base-method rename breaks its overriders — on purpose”override’s always-on check earns its keep the day someone renames a base method. Suppose Animal.makeSound gets renamed to speak in a refactor, and the person doing the rename never sees Dog, three files away, that still has override makeSound():
// @expect-error TS4113class Animal { speak() { return "..."; } // renamed from makeSound}
class Dog extends Animal { override makeSound() { return "Woof"; } // now orphaned}error TS4113: This member cannot have an 'override' modifier because it is not declared in the base class 'Animal'.Without override, this rename would silently turn Dog.makeSound into a brand-new, unrelated method that nothing calls — Dog instances would quietly stop making a sound the way the original design intended, with zero diagnostics anywhere. override turns a silent behavioral regression into a compile error, pointing at the exact orphaned method, the moment the base class changes shape. This is the single biggest reason to write override on every genuine override in a codebase you expect to be refactored.
noImplicitOverride, flag off vs on
Section titled “noImplicitOverride, flag off vs on”The always-on check above only catches an override that shouldn’t be there. It says nothing about the opposite mistake: quietly overriding a base method without writing override at all, which reads exactly like declaring a brand-new method to anyone skimming the subclass:
class Animal { makeSound() { return "..."; }}
class Dog extends Animal { makeSound() { return "Woof"; } // is this new, or an override? nothing here says.}This fence compiles with zero errors under this course’s default settings — noImplicitOverride is not part of strict, so it is off unless a project opts in. Turning it on in scratch with the exact same source reverses the result:
$ node_modules/typescript/bin/tsc --ignoreConfig --noEmit --strict --target es2025 file.ts$ echo exit=$?exit=0
$ node_modules/typescript/bin/tsc --ignoreConfig --noEmit --strict --noImplicitOverride --target es2025 file.tsfile.ts(5,3): error TS4114: This member must have an 'override' modifier because it overrides a member in the base class 'Animal'.$ echo exit=$?exit=1With noImplicitOverride on, every override in the codebase — intentional or not — has to say so in the source. Combined with the always-on typo check from the previous section, the two settings cover both directions: an override that shouldn’t be there, and one that should be but isn’t.
static members
Section titled “static members”static members belong to the class itself, not to instances. They exist at runtime as properties on the constructor function.
class Circle { static readonly PI = 3.14159; static area(r: number) { return Circle.PI * r * r; }}
Circle.area(2); // called on the class, not an instancestatic {} initialization blocks
Section titled “static {} initialization blocks”A static {} block (TypeScript 4.4 / ES2022) runs once, when the class itself is evaluated, before any instance exists. It has its own scope, can contain arbitrary setup logic that a single field initializer expression can’t express, and — inside it — this refers to the class itself:
class Config { static env: string; static { // runs once, at class-evaluation time -- `this` here is the class Config.env = typeof process !== "undefined" ? "node" : "browser"; } static describe(this: typeof Config) { return `running in ${this.env}`; }}
console.log(Config.describe());// @runclass Config { static env: string; static { Config.env = typeof process !== "undefined" ? "node" : "browser"; } static describe(this: typeof Config) { return `running in ${this.env}`; }}console.log(Config.describe());running in nodeOne sharp edge: a static readonly field cannot be assigned inside a static {} block, even though an instance readonly field can be assigned inside the constructor. Marking env above as static readonly and assigning it inside the block produces TS2540: Cannot assign to 'env' because it is a read-only property. — a static {} block is not treated as “the declaration” the way a constructor is for instance fields, so a static readonly field can only be initialized in its own declaration.
this in a static method
Section titled “this in a static method”Inside a static method, this refers to the class (or, in a subclass, the subclass — it is polymorphic the same way instance this is), not an instance. new this() is a common pattern for a static factory that must return the calling subclass, not always the base:
class Widget { static create() { return new this(); // `this` is the class actually called -- polymorphic across subclasses }}class Gadget extends Widget {}
const g = Gadget.create();console.log(g instanceof Gadget); // true -- create() returned a Gadget, not a bare Widgetabstract classes
Section titled “abstract classes”An abstract class cannot be instantiated directly; it exists to be extended. abstract methods declare a signature that subclasses must implement. The abstract markers are compile-time only.
abstract class Shape { abstract area(): number; // no body — subclasses must provide one describe() { return `area is ${this.area()}`; } // concrete method}
// new Shape(); // ❌ Cannot create an instance of an abstract class.
class Square extends Shape { constructor(private side: number) { super(); } // parameter property — needs emit, see "erasable syntax" area() { return this.side * this.side; } // required}implements is a check, not inheritance
Section titled “implements is a check, not inheritance”A class can implements an interface. This does not copy anything in — it is a compile-time assertion that the class structurally satisfies the interface. Because TypeScript is structural, a class can match an interface without implements; the clause just makes the intent explicit and turns a mismatch into an error at the class definition rather than at the use site.
interface Named { name: string; }
class Product implements Named { name = "widget"; // if this were missing, the error points HERE}#private really erases nothing; private erases everything
Section titled “#private really erases nothing; private erases everything”The two kinds of private from the top of this lesson aren’t just checked differently — they are emitted differently, and looking at the real compiler output settles any doubt about which one is a real runtime guarantee.
class Account { private secret = "shh"; #pin = "1234";}Compiling this with tsc --target es2025 (the emitted JavaScript, quoted verbatim — node_modules/typescript/bin/tsc --ignoreConfig --target es2025 --module esnext account.ts):
"use strict";class Account { secret = "shh"; #pin = "1234";}private vanished completely — secret is emitted as an ordinary, fully public field, because private was never anything but a TypeScript-only annotation. #pin is untouched: #-prefixed fields are native ECMAScript syntax, so the compiler has nothing to erase. The runtime consequence follows directly:
// @runclass Account { private secret = "shh"; #pin = "1234"; reveal() { return (this as any).secret; // reads straight through the erased annotation }}const a = new Account();console.log((a as any).secret); // ✅ runs -- private was only ever a compile-time labelconsole.log(a.reveal());shhshhBoth lines print shh. (a as any).secret compiles and runs without complaint, because at runtime there is no such thing as a private field here — only an ordinary property named secret that the type checker refuses to let you name without a cast. Try the same trick on #pin — (a as any)['#pin'] or even (a as any).#pin — and it fails: there is no string property key #pin to find, and #pin outside the class body is a SyntaxError at parse time, not a runtime access failure. private is a promise the compiler keeps only as long as you don’t ask it to look away; #private is a promise the language itself keeps.
Accessors: get/set, and accessor
Section titled “Accessors: get/set, and accessor”A get/set pair lets a property look like a plain field from the outside while running code on every read or write. Since TypeScript 4.3, the getter and setter no longer have to agree on the exact same type — the setter can accept a wider type than the getter returns, which is useful for a property that normalizes whatever it’s given:
class Temperature { #celsius = 0; get value(): number { return this.#celsius; } set value(input: number | string) { // wider than the getter's return type this.#celsius = typeof input === "string" ? Number(input) : input; }}
const t = new Temperature();t.value = "21.5"; // ✅ the setter accepts a string tooconst c: number = t.value; // ✅ but reading it back always gives the narrower numberThis compiles cleanly under --strict on TypeScript 7.0.2 — asymmetric accessors have been allowed since 4.3, unchanged through 7.
Writing a getter and setter that both just read and write the same private backing field is common enough that TypeScript 4.9 added a shorthand: the accessor keyword declares an auto-accessor — a real getter/setter pair, backed by an automatically generated private slot, from a single field-looking declaration:
class Counter { accessor count = 0;}The emitted JavaScript (tsc --target es2025) shows exactly what accessor expands to — a private storage slot plus a real get/set pair, not a plain field:
"use strict";class Counter { #count_accessor_storage = 0; get count() { return this.#count_accessor_storage; } set count(value) { this.#count_accessor_storage = value; }}Because that expansion is standard ECMAScript output (a # field plus ordinary accessors), accessor is erasable-safe the same way stage-3 decorators are — and, not coincidentally, accessor members are exactly what a stage-3 accessor decorator (covered in the Decorators lesson) is built to intercept.
The generic class that can’t infer: a one-line recap
Section titled “The generic class that can’t infer: a one-line recap”The Generic Classes & Interfaces lesson already covers this in depth: when a generic class’s type parameter has nothing to infer from at construction (new Stack() with no argument and no type annotation), T resolves to unknown, not any — parameter positions accept anything, but reading a value back out still requires narrowing first. If any of that is unfamiliar, see Generic Classes & Interfaces before continuing; everything below assumes you can read a class’s type parameters fluently.
The class-fields init-order bug: useDefineForClassFields
Section titled “The class-fields init-order bug: useDefineForClassFields”TypeScript’s default emit target for this course (ES2022+) uses native JavaScript class fields, which are installed using [[DefineOwnProperty]] (“define” semantics) rather than the ordinary assignment operator’s [[Set]] semantics. The distinction sounds academic until a base class sets a property before a subclass’s own field initializer has run — then it produces a genuinely surprising bug.
The base constructor’s write gets silently overwritten
Section titled “The base constructor’s write gets silently overwritten”// A framework base class that assigns arbitrary properties dynamically --// e.g. Object.assign(this, data) from a config object, common in ORMs,// "reactive props" mixins, and record base classes.class RecordBase { constructor(data: Record<string, unknown>) { Object.assign(this, data); }}
class UserRecord extends RecordBase { name: string = ""; // re-declared for typing -- carries its own initializer}
const u = new UserRecord({ name: "Ada" });console.log(u.name); // you might expect "Ada"...// @runclass RecordBase { constructor(data: Record<string, unknown>) { Object.assign(this, data); }}class UserRecord extends RecordBase { name: string = "";}const u = new UserRecord({ name: "Ada" });console.log(u.name);(empty string)The output is an empty string, not "Ada". super(data) runs first and calls Object.assign(this, data), which does set name to "Ada" — but only for a moment. Once super() returns, UserRecord’s own field initializers run next, as native class-field semantics require, and name: string = "" unconditionally re-defines name back to "" — overwriting the value the base constructor just set. Nothing here looks wrong reading top to bottom; the bug is entirely about the order two different mechanisms run in.
The fix: declare removes the field from the emit entirely
Section titled “The fix: declare removes the field from the emit entirely”class RecordBase { constructor(data: Record<string, unknown>) { Object.assign(this, data); }}
class UserRecord extends RecordBase { declare name: string; // type-only -- no [[DefineOwnProperty]] is emitted for this field}
const u = new UserRecord({ name: "Ada" });console.log(u.name); // "Ada" -- nothing re-defines it after Object.assign runs// @runclass RecordBase { constructor(data: Record<string, unknown>) { Object.assign(this, data); }}class UserRecord extends RecordBase { declare name: string;}const u = new UserRecord({ name: "Ada" });console.log(u.name);Adadeclare tells the compiler “trust me, this property exists at runtime through some other means” without emitting anything for it at all — no field, no initializer, no [[DefineOwnProperty]] call to clobber what Object.assign already wrote. This is the idiomatic fix any time a subclass needs to type a property that a base class (or a decorator, or a framework runtime) is really responsible for creating.
[[Define]] vs [[Set]], side by side
Section titled “[[Define]] vs [[Set]], side by side”The flag controlling this is useDefineForClassFields, and it defaults to true whenever the compile target is ES2022 or later (which is why the bug above shows up under this course’s settings without setting anything explicitly). Compiling the exact same source with the flag forced off shows the emitted difference directly — a subclass field with no initializer at all emits nothing, so it can never shadow anything the base class provides:
$ node_modules/typescript/bin/tsc --ignoreConfig --target es2025 --module esnext --useDefineForClassFields false accessor-shadow.tsGiven this source (a base class whose value is a real get/set accessor — a stand-in for a UI framework’s reactive-property system):
// @expect-error TS2564 TS2610class ReactiveBase { #stored = "framework-default"; get value() { return this.#stored; } set value(v: string) { this.#stored = v; console.log(`setter ran: ${v}`); }}
class Widget extends ReactiveBase { value: string; // plain re-declaration -- no initializer}the default (useDefineForClassFields: true, the ES2022+ native behavior) emits a bare field declaration that still installs a fresh own property at construction time, permanently shadowing the inherited accessor:
class Widget extends ReactiveBase { value; // -- installs an own data property, hiding ReactiveBase's accessor forever}running which prints undefined on read and never logs setter ran at all — the accessor is gone. With --useDefineForClassFields false, the identical source instead emits nothing for the field:
class Widget extends ReactiveBase {}and the base class’s accessor survives untouched — the setter really runs, and value reads and writes through it correctly. TypeScript’s type checker actually flags this exact pattern on sight, independent of the flag: TS2610: 'value' is defined as an accessor in class 'ReactiveBase', but is overridden here in 'Widget' as an instance property. — worth heeding even though (as this section just showed) the runtime consequence genuinely depends on useDefineForClassFields.
Object.assign in a base constructor, undone by a subclass field
class RecordBase { constructor(data: Record<string, unknown>) { Object.assign(this, data); }}
class UserRecord extends RecordBase { name: string = ""; // -- ES2022 class-field semantics re-define this AFTER super() runs}
const u = new UserRecord({ name: "Ada" });console.log(u.name); // "" -- not "Ada" -- the field initializer wins the race Click Run: an empty string, not "Ada". Change name: string = ""; to declare name: string; and click Run again to see the fix.
instanceof isn’t checking shape
Section titled “instanceof isn’t checking shape”instanceof walks an object’s prototype chain looking for the class’s prototype — it is a runtime identity check, not a structural one, and it has real limits worth knowing before you reach for it in generated or defensive code.
class Money { cents: number; constructor(cents: number) { this.cents = cents; }}
// An object that looks exactly like a Money instance -- same shape, same// prototype -- but was never built by calling `new Money(...)` (it could// have crossed a JSON boundary and been re-attached to the prototype by hand).const fromApi = Object.assign(Object.create(Money.prototype), { cents: 500 });console.log(fromApi instanceof Money); // true -- same prototype, so instanceof agrees
const plainDuck = { cents: 500 }; // identical shape, ordinary Object.prototypeconsole.log(plainDuck instanceof Money); // false -- instanceof only looks at the prototype chain// @runclass Money { cents: number; constructor(cents: number) { this.cents = cents; }}const fromApi = Object.assign(Object.create(Money.prototype), { cents: 500 });console.log(fromApi instanceof Money);const plainDuck = { cents: 500 };console.log(plainDuck instanceof Money);truefalseTwo related, well-known gaps: instanceof fails across realms — an array built inside an <iframe>, a separate vm context, or a worker has its own Array.prototype, so arr instanceof Array can be false for a perfectly real array that simply came from somewhere else — and it fails for any duck-typed object built without going through the real constructor, exactly like plainDuck above. Data that has been serialized and parsed (JSON, a message-passing boundary, a database row) never had a prototype chain to begin with; checking instanceof against it is checking the wrong thing entirely.
Symbol.hasInstance: making instanceof shape-aware on purpose
Section titled “Symbol.hasInstance: making instanceof shape-aware on purpose”instanceof is itself customizable: a class (or any object) can implement [Symbol.hasInstance] to redefine what x instanceof C means, including switching it to a genuine structural check:
interface Money { cents: number }
class MoneyChecker { static [Symbol.hasInstance](obj: unknown): obj is Money { return typeof obj === "object" && obj !== null && typeof (obj as Money).cents === "number"; }}
console.log({ cents: 500 } instanceof MoneyChecker); // true -- shape-based now, custom hookconsole.log({ dollars: 5 } instanceof MoneyChecker); // false// @runinterface Money { cents: number }class MoneyChecker { static [Symbol.hasInstance](obj: unknown): obj is Money { return typeof obj === "object" && obj !== null && typeof (obj as Money).cents === "number"; }}console.log({ cents: 500 } instanceof MoneyChecker);console.log({ dollars: 5 } instanceof MoneyChecker);truefalseThis is a real, useful escape hatch, but it’s easy to overuse for plain data. For data crossing a boundary — parsed JSON, a database row, a message payload — a discriminant property checked with a plain if (the pattern from the narrowing lesson’s discriminated unions) is simpler, doesn’t require customizing a language operator, and is what most real-world validation code and schema libraries actually reach for. Save Symbol.hasInstance for genuine identity checks against real class instances that might legitimately cross a realm boundary.
Mental Model
Section titled “Mental Model”For every class feature in this lesson, ask “does this exist in the emitted JavaScript, or only in the type checker’s head?” private, protected, abstract, implements, and override all disappear on compile — they are checked once and then gone, which is why a determined caller can defeat any of them with any. #private fields, static members, static {} blocks, the accessor keyword’s private slot, and the field-initializer assignments behind useDefineForClassFields are all real code that runs — which is exactly where the surprising bugs in this lesson (the erasure gap, the init-order bug, the accessor-shadowing bug) come from: two different runtime mechanisms racing or colliding, not a compiler mistake.
Trade-offs
Section titled “Trade-offs”| Feature | Benefit | Cost |
|---|---|---|
private / protected | Documents intent, zero runtime cost, catches honest mistakes | Erased on emit — not a real security boundary; (obj as any).field reaches it |
#private | Real runtime privacy, unforgeable, works as a brand check (#x in obj) | Slightly more ceremony; can’t be accessed even from legitimate subclasses (unlike protected) |
override (always on) | Catches an orphaned override after any base-class rename, with no config | Doesn’t catch the opposite mistake — a silent override with no label |
noImplicitOverride | Catches every silent override too | Not part of strict; an opt-in most codebases don’t turn on |
Field initializer (name = "") | Concise, guarantees a default value | Under useDefineForClassFields, always re-defines the property after super(), even when a base class already set it through another mechanism |
declare field | No emit at all — never shadows a base-provided value | Purely a type annotation; the runtime value must genuinely be provided some other way, or reads return undefined |
instanceof | Fast, built-in, no code to write | Fails across realms and for duck-typed/deserialized data — checks identity, not shape |
Symbol.hasInstance | Makes instanceof shape-aware on purpose | Customizes a language operator — a plain discriminant check is simpler for most data-shape checks |
Common Misconceptions
Section titled “Common Misconceptions”privateprotects a field from being read at runtime. It doesn’t — it’s erased entirely, and the emitted JavaScript shows the field as a fully ordinary, public property. Only#privateis a real runtime boundary.overridealone means “safe against base-class changes.” It only catches the case where the base member is gone (TS4113, always on). Catching a silent, unlabeled override of a member that’s still there requires the separate, opt-innoImplicitOverrideflag.- A subclass field declaration is “just documentation” if it has no initializer, or is harmless if it repeats a default the base already sets. Under
useDefineForClassFields(the default from ES2022 on), any field declaration — initializer or not — installs a fresh own property at construction time, which can silently overwrite or shadow a value a base class set through a constructor side effect or an inherited accessor. instanceofchecks whether an object has the right shape. It checks whether the object’s prototype chain includes the constructor’sprototype— a purely identity-based, not structural, question. Deserialized data and cross-realm objects routinely failinstanceofwhile having an identical, valid shape.
Reading AI-generated code
Section titled “Reading AI-generated code”Generated class code tends to lean on exactly the mechanisms this lesson just showed have sharp edges:
- A subclass re-declares a field the base class is “supposed to” provide (via a constructor side effect, a decorator, or a framework base class). Ask whether the base actually assigns it before or after
super()returns relative to the subclass’s own field initializers. If the base sets it through anything other than a plain field initializer on the base itself, the subclass field is very likely clobbering it —declareis almost always the fix. instanceofused to validate data that crossed a network or storage boundary (a parsed JSON response, a deserialized queue message) is a common generated-code smell: that data never had a prototype chain, so the check either always fails or was never actually exercised in the tests that “proved” it works. A discriminant property or a schema check belongs there instead.privateused as if it were a security boundary — e.g. storing a genuinely sensitive value (a token, a secret) in aprivatefield and treating that as sufficient protection. The lesson’s own emitted-JS example shows exactly why that’s false:(value as any).secretreads it with zero resistance.
🐛 Spot the bug
class WidgetBase {constructor(config: Record<string, unknown>) { Object.assign(this, config);}}
class Button extends WidgetBase {label: string = "Click me";}
const b = new Button({ label: "Submit" });console.log(b.label);Show the answer
This prints "Click me", not "Submit" — the opposite of what the call site’s argument suggests. super(config) runs Object.assign(this, config) first, which really does set label to "Submit" for a moment. But Button’s own field initializer, label: string = "Click me", runs immediately after super() returns as part of native ES2022+ class-field semantics, and unconditionally re-defines label back to its default — overwriting whatever the base constructor’s Object.assign just wrote. The fix is declare label: string; on Button: a type-only declaration emits no field at all, so nothing runs after super() to clobber the value Object.assign provided.