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

// 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