Classes
This chapter assumes you already know the prototype chain, how this binding works in ordinary functions versus arrow functions, and what property descriptors are. Classes are syntax over those mechanics, and most confusion about classes dissolves once you trace what the syntax compiles down to.
Classes Are Functions
A class declaration creates a function with unconstructible-call semantics:
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
dist() {
return Math.hypot(this.x, this.y);
}
}
console.log(typeof Point); // "function"
console.log(Object.getPrototypeOf(Point.prototype)
=== Object.prototype); // true
const p = new Point(3, 4);
console.log(p.dist()); // 5The differences from a hand-written constructor function are behavioral, not structural:
- Class bodies run in strict mode regardless of where the class appears.
- Calling
Point(3, 4)withoutnewthrows aTypeError; legacy constructor functions silently operate on the global object. - Methods defined in the class body are non-enumerable and installed on
Point.prototypeas writable, non-enumerable data properties. The prototype object itself is non-writable and non-extensible. - Class declarations are hoisted but not initialized: accessing the binding before evaluation throws a
ReferenceError(the temporal dead zone, same aslet). - The class name binding inside the class body is an immutable inner binding, so
Point = Xoutside works but a shadowing reassignment inside fails.
Instance Fields, Static Members, and Private State
Since ES2022, class bodies can declare fields directly, including private fields with #:
class Counter {
static instances = 0; // static field on the constructor
#count = 0; // private instance field
static from(n) { // static method
const c = new Counter();
c.#count = n; // private access is lexical, not structural
return c;
}
get value() { return this.#count; } // accessor pair
set value(n) { if (n < 0) throw new RangeError('negative'); this.#count = n; }
tick() { return ++this.#count; }
}
console.log(Counter.instances); // 0
console.log(Counter.from(10).tick()); // 11Key details:
- Instance fields are installed per-instance, not on the prototype. Each object gets its own property slot, which matters for memory in high-count scenarios.
- Private
#fields are hard-private: no external access, not even viaObject.getOwnPropertyNamesorReflect. Subclass code cannot reach a parent's private field unless declared in the same class body. staticblocks (static { ... }) let you run setup code with access to private state during class evaluation.- Field initializers run in declaration order during construction, after
super()returns in subclasses — see the gotcha below.
callout warning
Field initializers shadow inherited accessors silently. If a subclass declares x = 1 while the base class defines a set x() accessor, the subclass's own data property replaces the inherited setter for its instances. Prefer explicit constructor assignment when you intend to route through a base accessor.
Subclassing and super
extends builds a real prototype chain between constructors, not just instances:
class Base {
constructor(x) { this.x = x; }
describe() { return `x=${this.x}`; }
}
class Derived extends Base {
constructor(x, y) {
super(x); // required before touching `this`
this.y = y;
}
describe() {
return `${super.describe()}, y=${this.y}`;
}
}
console.log(Object.getPrototypeOf(Derived) === Base); // true
console.log(Object.getPrototypeOf(Derived.prototype) === Base.prototype); // trueNote the two-chain structure: Derived's prototype is Base (so static members inherit), and Derived.prototype's prototype is Base.prototype (so instance methods inherit). This differs from older mixin patterns that only linked instance prototypes.
super.describe() performs a lookup on Base.prototype and calls it with the current this; it is a static syntactic form, so it cannot be extracted or rebound.
The Method Inheritance Gotcha
Class methods are stored as properties on the prototype and copied nowhere, so "overwriting" a method later mutates it for every instance — including instances already created:
class A { run() { return 'A'; } }
const a = new A();
A.prototype.run = () => 'patched';
console.log(a.run()); // "patched"This is useful for patching and dangerous for libraries that assign methods onto instances versus the prototype. Contrast: fields, because they are per-instance, are snapshot at construction time and immune to later prototype mutation.
class B { run = () => 'B'; }
const b = new B();
B.prototype.run = () => 'patched';
console.log(b.run()); // "B"Arrow fields also bind this lexically at construction — handy for callbacks, at the cost of one closure per instance.
New.target and Abstract-ness
Inside a constructor, new.target is the constructor that was invoked with new, which enables enforced abstractness and polymorphic construction:
class Shape {
constructor() {
if (new.target === Shape) {
throw new TypeError('Shape is abstract');
}
}
}
class Circle extends Shape {
constructor(r) { super(); this.r = r; }
}
new Circle(2); // fine
new Shape(); // TypeErrorBecause new.target is the derived constructor during subclass instantiation, base constructors can also return instances of the concrete subclass — the standard pattern before static factory methods and still useful for Proxy-based construction.
Mixins Without Multiple Inheritance
JavaScript has single inheritance. The idiomatic workaround is a function returning a class expression extending a parameter:
const Timestamped = (Base) => class extends Base {
stampedAt = new Date();
age() { return Date.now() - this.stampedAt.getTime(); }
};
class Record {}
class LogEntry extends Timestamped(Record) {}
console.log(new LogEntry() instanceof Record); // trueEach Timestamped(X) call creates a fresh class, so identity checks like instanceof Timestamped are meaningless — mixins are structural in use but nominal per call. Prefer Object.assign(MyClass.prototype, mixinMethods) when you need one stable shared mixin object, at the cost of losing super and constructor hooks.
Decision Criteria
| Need | Reach for |
|---|---|
| Shared behavior, many instances | Class methods on the prototype |
| Per-instance bound callbacks | Arrow function fields |
| Truly hidden state | `#` private fields, not `_`-prefixed properties or `WeakMap` side tables (unless you need cross-class or per-instance privacy semantics) |
| Ad-hoc composition | Mixin factory or `Object.assign` on the prototype |
| Behavioral interception (logging, validation) | `Proxy`, not class overrides, when you cannot modify the class |
Prefer plain objects with functions when there is no behavior-and-identity modeling — a class adds constructor ceremony that pure data does not need.
caption: Prototype links for a class hierarchy and its instance.
alt: Diagram showing Derived extending Base, and an instance of Derived pointing to Derived.prototype via its prototype link, with method lookup flowing up to Base.prototype.
Exercise
A subclass declares an instance field initialized before calling super() in its constructor. What happens, and why?
Show answer
It throws a ReferenceError. Field initializers compile to statements that read and write this, and this is uninitialized until super() returns in a derived constructor. The correct order is: call super() first, then declare or assign fields (or place them as class fields, which run automatically after super()).
Exercise
Why can code outside a class not access a #private field even with Reflect.get and the instance in hand?
Show answer
Private fields are not properties at all. They are internal slots keyed by the class's lexical declaration and a brand check installed at construction. Reflect and property APIs operate on the property map, which never contains # fields, so no external mechanism can reach them — by design.
Exercise
new.target === Shape guards abstractness, but an attacker calls Reflect.construct(Shape, [], Circle). Does the guard hold, and what does this tell you about class privacy generally?
Show answer
It holds: Reflect.construct sets new.target to the third argument (Circle), so the guard sees Circle and allows construction — producing a Shape-branded object created as if by Circle. The broader lesson: runtime guards are conventions that the reflection layer can route around or satisfy; hard invariants need # fields or closures, not checks on public call context.
Spotted a mistake, or code that does not work?