A class is a function with sugar
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.
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
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
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.
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.
// 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.
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.
// 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.
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