Classes
Fields, private names, inheritance, super,
initialization order, mixins, abstract classes, and runtime
identity.
High-level overview
A class declaration creates a constructor function and a prototype
object. When code runs new Widget(), JavaScript creates a
Widget instance, initializes the instance fields, and runs
the constructor. Ordinary methods live on Widget.prototype;
static members live on Widget itself.
A derived class adds a strict order. JavaScript finishes the
base-class setup before JavaScript creates fields declared by the
derived class. In a derived constructor, super() performs
the base-class construction and makes this available to the
derived constructor.
Things to focus on
- Class members live in different places. An instance field becomes an own property of each instance. A normal method is a shared property of the class prototype. A static field or method is a property of the constructor function rather than of an instance.
- Arrow fields and normal methods handle
thisdifferently. An arrow field creates a separate function for each instance and captures that instance asthis. A normal method is shared; the expression that calls the method supplies the method’sthisvalue. - A field initializer defines an own property. A
field such as
x = 1does not performthis.x = 1through the prototype chain. An inherited setter forxtherefore does not run. - A
#privatename is not a normal property. JavaScript enforces access to#xat runtime. TypeScript’sprivate xis a checker restriction on an ordinary JavaScript property. - Subclass construction has a fixed sequence.
new Derived()runs base instance fields, the base constructor, derived instance fields, and then the remainder of the derived constructor aftersuper(). Readingthisbeforesuper()in a derived constructor throws. - Base constructors must not rely on subclass state. A base constructor can call an overridden method, but derived fields do not exist at that point.
extendsaccepts an expression. A mixin is commonly a function that takes a base class and returns a derived class. A static block runs once when JavaScript evaluates the class definition.- Several TypeScript class keywords have no runtime
effect.
abstract,implements,override,readonly, and a definite-assignment assertion (!) change type checking but do not add JavaScript behavior. - Runtime identity can be customised. A class is
still a function at runtime.
instanceofand theObject.prototype.toStringtag can be changed withSymbol.hasInstanceandSymbol.toStringTag.
// 04 — CLASSES
//
// A reading file. Read it straight down: the comment above each construct gives
// its NAME, and `// =>` gives what the expression on its left evaluates to, so
// nothing is left hanging. Each section uses only what the lines just above it
// introduced.
//
// It still runs (`node 04-classes.ts`) and prints nothing; the `// =>`
// annotations are the output, already collected.
//
// Class bodies are ALWAYS strict mode. Class declarations hoist, but sit in the
// TDZ until their line runs — unlike function declarations.
//
// Mark anything you can't explain out loud with // ?
// ─── 1. THE FULL MEMBER SURFACE ──────────────────────────────────────────────
//
// A class is the prototype chain from file 03 with better syntax on top — plus
// one thing genuinely new (`#private`) and one thing you have to memorise (the
// order everything initialises in). For each member, ask two questions: does it
// live on the INSTANCE or the PROTOTYPE, and WHEN does it come into existence?
const computedKey = "computed";
const definitionLog: string[] = []; // declared first, so the static block can reach it
class Full {
// INSTANCE FIELD — one per object, created in source order before the
// constructor body runs
instanceField = 1;
// STATIC FIELD — one per CLASS, created when the class is defined, not when
// you construct anything
static staticField = "static";
// `#private` — real privacy the runtime enforces. It is not a property at all:
// it never appears in Object.keys, JSON.stringify, or a debugger's property
// list. Compare TypeScript's `private` below, which is a promise rather than a
// mechanism (40.4).
#privateField = "hidden";
#privateMethod() { // PRIVATE METHOD
return this.#privateField;
}
static #staticPrivate = "static private"; // STATIC PRIVATE
// `readonly` — checker-only. It stops YOU reassigning it and stops nothing at
// runtime.
readonly readonlyField = "cannot reassign (at compile time)";
// public/private/protected — all three are erased. `private` here still emits
// an ordinary property that anyone can read with obj["privateSoft"].
public publicField = 1;
private privateSoft = "readable at runtime, unlike #privateField";
protected protectedField = true;
// (TypeScript can also declare and assign a field straight from a constructor
// parameter — "parameter properties". That form does NOT erase, so it lives
// in file 10 with the rest of the seam.)
constructor(initial: number) {
// every field above already exists by the time you arrive here
this.instanceField = initial;
}
// METHOD — lives on the PROTOTYPE. One function object shared by every
// instance, and non-enumerable, so it stays out of Object.keys (25.5).
method() {
return this.#privateMethod();
}
// ARROW FIELD — lives on the INSTANCE, and captures `this` where it is
// written, so it survives being pulled off the object. That is why event
// handlers use it. The cost: one function object per instance, and it is
// enumerable. See 24.4.
boundMethod = () => this.instanceField;
get computedValue() { // ACCESSOR PAIR — one property, on the prototype
return this.instanceField * 2;
}
set computedValue(v: number) {
this.instanceField = v / 2;
}
// (The `accessor` keyword — which auto-generates a getter/setter pair over a
// hidden private slot — is not yet in V8, so it lives in file 10.)
[computedKey]() { // COMPUTED MEMBER NAME
return "computed member";
}
static create(n: number) { // STATIC METHOD
return new Full(n);
}
// STATIC BLOCK — runs once when the class is defined, with `this` = the class.
// A static field can only hold an expression; this is where statements go.
static {
definitionLog.push(`static block ran, saw ${Full.#staticPrivate}`);
}
// `#field in obj` — the ERGONOMIC BRAND CHECK. Private fields cannot be faked,
// so this is the only unforgeable "is this really one of ours" test available.
static isFull(o: object) {
return #privateField in o;
}
}
const ranAtDefinition = definitionLog; // => ["static block ran, saw static private"]
// It ran above without anyone constructing anything.
const full = new Full(1);
const throughPrivate = full.method(); // => "hidden" — a public method is how the outside
// reaches a private field
const accessorValue = full.computedValue; // => 2 — instanceField (1) doubled
const computedMember = full.computed(); // => "computed member"
const brandTrue = Full.isFull(full); // => true
const brandFalse = Full.isFull({}); // => false — and no copy of the shape can fake it
const softPrivate = (full as any)["privateSoft"]; // => "readable at runtime, unlike
// #privateField" — TypeScript's `private` was a checker rule, and it is gone now
// ─── 2. INHERITANCE ──────────────────────────────────────────────────────────
//
// One rule above all others here: the parent constructor runs FIRST, and the
// subclass's own fields do not exist until it returns. Almost everything
// surprising about class construction is that rule showing up somewhere.
class Base {
name: string;
constructor(name: string) {
this.name = name;
}
describe() {
return `Base(${this.name})`;
}
static staticInherited() {
return "statics are inherited too";
}
}
class Derived extends Base {
extra: number;
constructor(name: string, extra: number) {
super(name); // MUST run before any use of `this` — `this` is in a TDZ until it does
this.extra = extra;
}
// `override` — checker-only. It confirms you really are overriding something,
// so renaming the base method doesn't silently orphan this one.
override describe() {
return `Derived(${super.describe()})`; // `super.` reaches the prototype's version
}
}
const derived = new Derived("d", 1);
const overrideChain = derived.describe(); // => "Derived(Base(d))"
const isABase = derived instanceof Base; // => true
const staticThroughSubclass = Derived.staticInherited(); // => "statics are inherited too"
const staticsChain = Object.getPrototypeOf(Derived) === Base; // => true — the class
// objects themselves are linked, which is what makes that call work
// Here is the sharpest edge in the whole class system. Trace it slowly: `new
// SubWithField()` runs the base constructor, which calls this.hook(). Method
// lookup is live, so it finds SUB's hook — a method on a class whose fields have
// not been created yet. The object exists; it is only half-built.
const hookLog: unknown[] = [];
class BaseCallsHook {
constructor() {
this.hook(); // runs BEFORE the subclass's field initialisers
}
hook() {}
}
class SubWithField extends BaseCallsHook {
field = "initialised";
override hook() {
hookLog.push(this.field);
}
}
const sub = new SubWithField();
const duringConstruction = hookLog; // => [undefined] — the field was not there yet
const afterConstruction = sub.field; // => "initialised"
// The lesson generalises: a base constructor must never call a method a subclass
// might override.
// The `!` is a DEFINITE ASSIGNMENT ASSERTION: "something assigns this, stop
// asking". It does not change the type — the field is still `string`, never
// `string | undefined` — so you have turned off a check rather than described
// reality.
class LateInit {
value!: string;
init() {
this.value = "set later";
}
}
const late = new LateInit();
const beforeInit = late.value; // => undefined — the type says `string`, and it is lying
late.init();
const afterInit = late.value; // => "set later"
// Leave out the constructor and you get `constructor(...args) { super(...args) }`
// for free, which is why most subclasses need not write one.
class Implicit extends Base {}
const implicitlyForwarded = new Implicit("implicit").describe(); // => "Base(implicit)"
class MyArray<T> extends Array<T> { // EXTENDING A BUILTIN
last(): T | undefined {
return this[this.length - 1];
}
}
const extendedBuiltin = new MyArray<number>().concat([1, 2]).length; // => 2
const stillMyArray = new MyArray<number>().concat([1, 2]) instanceof MyArray; // => true
// — by default the builtin methods build a new instance of YOUR class
// ...which `Symbol.species` is how you opt out of: it answers "what constructor
// should derived results use?"
class PlainResults extends Array<number> {
static override get [Symbol.species]() {
return Array;
}
}
const species = new PlainResults();
species.push(1, 2);
const mappedIsSubclass = species.map((n) => n) instanceof PlainResults; // => false
const mappedIsArray = species.map((n) => n) instanceof Array; // => true
// Accessors are overridable like methods, and `super` reaches the base accessor.
class BaseAccessor {
get label() {
return "base";
}
}
class SubAccessor extends BaseAccessor {
override get label() {
return `sub(${super.label})`;
}
}
const accessorOverride = new SubAccessor().label; // => "sub(base)"
// `extends` takes an EXPRESSION, evaluated when the class is defined. So a
// function can return a class — and that is the entire basis of MIXINS.
const Serializable = <T extends new (...args: any[]) => object>(Bass: T) =>
class extends Bass {
serialize() {
return JSON.stringify(this);
}
};
class Mixed extends Serializable(Base) {}
const mixedIn = new Mixed("mixed").serialize(); // => '{"name":"mixed"}'
const mixedIsBase = new Mixed("mixed") instanceof Base; // => true — a real chain, built
// by an ordinary function call
// ─── 3. ABSTRACT (TYPESCRIPT ONLY) ───────────────────────────────────────────
//
// None of this exists at runtime. `abstract` members vanish entirely and the
// class becomes an ordinary constructible class — "cannot instantiate" is
// enforced by the checker and by nothing else.
abstract class AbstractShape {
abstract area(): number; // no body: subclasses must supply one, and the checker
// refuses to let you construct this class directly
abstract readonly kind: string;
report() { // concrete members are allowed alongside abstract ones
return `${this.kind}: ${this.area()}`;
}
}
class Circle extends AbstractShape {
readonly kind = "circle";
private r: number;
constructor(r: number) {
super();
this.r = r;
}
area() {
return Math.PI * this.r ** 2;
}
}
const abstractReport = new Circle(1).report(); // => "circle: 3.141592653589793"
// `new AbstractShape()` — TS2511: cannot create an instance of an abstract class.
// The emitted JavaScript would run it perfectly well.
// ─── 4. INTERFACES AND implements ────────────────────────────────────────────
//
// `extends` builds a real prototype chain. `implements` is a promise the checker
// verifies and then forgets. Keeping those two apart is most of understanding
// what TypeScript does to a class.
interface Comparable<T> {
compareTo(other: T): number;
}
interface Printable {
print(): string;
}
// `implements` adds nothing: no members, no inference inside the class body, no
// runtime trace. It only checks that you kept your word — and you can promise
// several things at once.
class Money implements Comparable<Money>, Printable {
cents: number;
constructor(cents: number) {
this.cents = cents;
}
compareTo(other: Money) {
return this.cents - other.cents;
}
print() {
return `$${(this.cents / 100).toFixed(2)}`;
}
}
const printed = new Money(150).print(); // => "$1.50"
const compared = new Money(1).compareTo(new Money(2)); // => -1
// ─── 5. CLASS EXPRESSIONS AND `this` TYPES ───────────────────────────────────
const Anonymous = class { // CLASS EXPRESSION
value = 1;
};
const NamedExpr = class InnerName { // NAMED CLASS EXPRESSION — `InnerName` is visible
self() { // only inside the body, and cannot be reassigned. The same private
return InnerName.name; // self-reference a named function expression gets (02).
}
};
const anonymousValue = new Anonymous().value; // => 1
const innerName = new NamedExpr().self(); // => "InnerName"
const outerName = Anonymous.name; // => "Anonymous" — inferred from the assignment
// `this` as a RETURN TYPE means "whatever the receiver turns out to be", so a
// subclass keeps its own type through a chain. Writing the class name instead
// would throw the subclass away after the first call.
class Builder {
private parts: string[] = [];
add(part: string): this {
this.parts.push(part);
return this;
}
build() {
return this.parts.join(" ");
}
}
class FancyBuilder extends Builder {
addFancy() {
return this.add("fancy");
}
}
const built = new FancyBuilder().add("a").addFancy().add("b").build(); // => "a fancy b"
// `.add()` returned `this`, so `.addFancy()` was still reachable after it.
// ─── 6. RUNTIME IDENTITY ─────────────────────────────────────────────────────
//
// Underneath, a class is a function with a prototype object — and most of what
// `instanceof` and `toString` report can be overridden, which matters when you
// are debugging something that lies about what it is.
class Surface {
instanceField = 1;
#secret = "kept";
method() { // on the prototype
return this.#secret;
}
boundMethod = () => this.#secret; // on the instance
get accessor() { // on the prototype
return 1;
}
}
const surface = new Surface();
const classIsFunction = typeof Surface; // => "function" — classes really are functions
const onThePrototype = Object.getOwnPropertyNames(Surface.prototype);
// => ["constructor", "method", "accessor"]
const onTheInstance = Object.keys(surface); // => ["instanceField", "boundMethod"]
// There is the answer to section 1's first question, for every member at once.
const constructorName = surface.constructor.name; // => "Surface" — found on the prototype
// `instanceof` is not set in stone: an object can decide for itself.
class EvenNumber {
static [Symbol.hasInstance](n: unknown) {
return typeof n === "number" && n % 2 === 0;
}
}
// @ts-expect-error TS2358: TS wants an object on the left; the runtime is happy
const fourIsEven = 4 instanceof (EvenNumber as any); // => true
// @ts-expect-error TS2358: same reason
const threeIsEven = 3 instanceof (EvenNumber as any); // => false
// ...and neither is the [object Something] tag.
class Tagged {
get [Symbol.toStringTag]() {
return "Tagged";
}
}
const taggedString = Object.prototype.toString.call(new Tagged()); // => "[object Tagged]"
const plainString = Object.prototype.toString.call({}); // => "[object Object]"
// The payoff of section 1: pull both callables off the instance and only one
// still works, because only one of them ever needed a receiver.
const looseArrow = surface.boundMethod;
const stillWorks = looseArrow(); // => "kept" — it captured `this` when it was created
const looseMethod = surface.method;
let extractionError = "";
try {
looseMethod();
} catch (e) {
extractionError = (e as Error).constructor.name; // => "TypeError"
}
const brokenBy = extractionError; // => "TypeError" — reading a private field needs the
// receiver the method never carried