Classes and prototypes

Write a class with real encapsulation, and understand the prototype underneath it.

14of 32
≈ 12Min read
8Parts

A class is a function with sugar

the whole syntax in one example
class Door {
  static count = 0;               // on the class itself
  #secret = "internal";           // truly private
  days = 5;                       // instance field, set before the constructor body

  constructor(name, days) {
    this.name = name;
    if (days != null) this.days = days;
    Door.count += 1;
  }

  get price() { return this.days * 100; }         // computed, read like a property
  set price(v) { this.days = v / 100; }

  describe() { return `${this.name}: ${this.days}d`; }   // on the prototype

  static from({ name, days }) { return new Door(name, days); }   // a named constructor
}

const d = new Door("Painted");
d.price;            // 500 — no parentheses
Door.count;         // 1
Door.from({ name: "Veneer", days: 10 });

Methods live on the prototype

A field holding an arrow is created per instance and keeps this; a method is shared and loses it when detached. Use a field for anything you will pass as a callback, and a method for everything else.

which is why they are shared and detachable
const a = new Door("A");
const b = new Door("B");

a.describe === b.describe;                   // true — one function, shared
Object.getPrototypeOf(a) === Door.prototype; // true

const f = a.describe;
f();                                          // TypeError — this is undefined

Private fields

#name is enforced by the language
class Counter {
  #n = 0;

  inc() { this.#n += 1; return this; }
  get value() { return this.#n; }

  static isCounter(x) { return #n in x; }    // a private brand check
}

const c = new Counter();
c.#n;                    // SyntaxError — not even at runtime
Object.keys(c);          // [] — invisible to JSON.stringify too
Underscore is a convention, # is a rule

this._n is private by agreement and visible in devtools, in Object.keys, and in JSON.stringify. #n is none of those things.

Inheritance

super(...) calls the parent constructor; super.method() calls the parent version. Forgetting super() in a subclass constructor is a ReferenceError, not a silent bug — the language will not let you touch this first.

extends and super
class GlassDoor extends Door {
  constructor(name, days, thickness) {
    super(name, days);            // MUST come before any use of this
    this.thickness = thickness;
  }

  describe() {
    return `${super.describe()} (${this.thickness}mm glass)`;
  }
}

new GlassDoor("Reeded", 20, 8) instanceof Door;   // true

Prefer composition

Inheritance couples a subclass to every detail of its parent. One level is usually fine; three is a rewrite waiting to happen. Ask whether the thing is a kind of the parent, or merely has some of its behaviour.

a deep hierarchy is a trap
// three levels down, changing the base breaks everything below
class Base {} class Mid extends Base {} class Leaf extends Mid {}

// composition: give the object what it needs
class Door {
  constructor(name, pricing) {
    this.name = name;
    this.pricing = pricing;        // any object with a price() method
  }
  get price() { return this.pricing.price(this); }
}

Extending built-ins

Setting this.name is worth doing: it is what appears at the front of the stack trace.

the useful case
class HttpError extends Error {
  constructor(status, message) {
    super(message ?? `HTTP ${status}`);
    this.name = "HttpError";
    this.status = status;
  }
}

try {
  throw new HttpError(404);
} catch (err) {
  if (err instanceof HttpError && err.status === 404) { }
}

Do you need a class at all?

Reach for a class when you have many instances of the same shape, when you need instanceof, or when a library expects one. For state with a few operations, a closure is smaller and has no this to lose.

often a closure or a plain object is enough
// a class
class Counter { #n = 0; inc() { return ++this.#n; } }

// a factory with a closure — same privacy, no this to lose
const counter = () => {
  let n = 0;
  return { inc: () => ++n, get value() { return n; } };
};

// plain data plus functions — easiest to test and serialise
const makeDoor = (name, days) => ({ name, days });
const priceOf = (door) => door.days * 100;

The prototype chain

A property lookup walks that chain until it finds the name or runs out. Object.hasOwn(obj, key) asks only about the object itself, which is why it is the right test when you are inspecting data.

what is really happening
const d = new Door("A");

d.describe;                                // not on d — found on Door.prototype
Object.getPrototypeOf(d) === Door.prototype;
Object.getPrototypeOf(Door.prototype) === Object.prototype;
Object.getPrototypeOf(Object.prototype);   // null — the end of the chain

Practice