Classes, prototypes and object-oriented patterns
The prototype chain, class syntax, private fields, static members, getters and setters, and when composition beats inheritance.
The prototype chain
JavaScript objects inherit from other objects. Each object has a hidden link to a prototype, and a property lookup walks that chain until it finds the name or reaches null. Classes are a readable way to build those links, not a different object model.
const base = { kind: 'shape', describe() { return this.kind; } };
const circle = Object.create(base); // circle's prototype is base
circle.radius = 2;
circle.describe(); // 'shape' - found one level up
Object.getPrototypeOf(circle) === base; // true
circle.hasOwnProperty('radius'); // true
circle.hasOwnProperty('kind'); // false - inherited, not own
// own keys only, versus everything reachable on the chain
Object.keys(circle); // ['radius']
for (const k in circle) console.log(k); // 'radius', 'kind', 'describe'
// functions get their prototypes from Function.prototype
function greet() {}
Object.getPrototypeOf(greet) === Function.prototype; // true- Reading walks the chain; assignment never does —
circle.kind = 'x'creates an own property that shadows the inherited one. Object.keys,JSON.stringifyand spread see own enumerable properties only.- Prefer
Object.create(null)for a plain lookup table when you do not want inherited names such asconstructorin the way.
Class syntax
The class keyword defines a constructor plus methods that live on the prototype, so every instance shares one copy of each function rather than carrying its own.
class Counter {
#count = 0; // truly private: not readable outside the class
static created = 0; // one value on the class itself
constructor(label = 'counter') {
this.label = label; // per-instance property
Counter.created += 1;
}
increment(by = 1) { // lives on Counter.prototype
this.#count += by;
return this;
}
get value() { return this.#count; } // read like a property
set value(v) {
if (v < 0) throw new RangeError('value must be >= 0');
this.#count = v;
}
static isCounter(x) { return x instanceof Counter; }
}
const c = new Counter('hits');
c.increment(2).increment(3); // chaining works because we return this
console.log(c.value, c.label); // 5 'hits'
c.value = 10;
console.log(Counter.created); // 1
// methods are not enumerable and not own properties of the instance
Object.keys(c); // ['label']
typeof Counter.prototype.increment; // 'function'
// a class body is always strict, and calling without new throws
try { Counter(); } catch (e) { console.log(e.message); }| Member | Where it lives | Accessible as |
|---|---|---|
constructor body assignments | Each instance | obj.label |
| Methods | Class.prototype | obj.method() |
#private fields and methods | Each instance, unlocked only inside the class | Not from outside, not via Object.keys |
static members | The constructor function | Class.member |
get/set | The prototype | Read or assign like a plain property |
Class field x = 1 | Each instance, set before the constructor body | obj.x |
const inc = c.increment; inc() throws or writes to undefined. Bind it (c.increment.bind(c)) or wrap it in an arrow function when you pass it as a callback, and note that private fields are reachable only through this.Inheritance and composition
class Shape {
constructor(name) { this.name = name; }
area() { throw new Error('area() must be implemented'); }
toString() { return this.name + ': ' + this.area().toFixed(2); }
}
class Rect extends Shape {
#w; #h;
constructor(w, h) { super('rect'); this.#w = w; this.#h = h; }
area() { return this.#w * this.#h; }
toString() { return '[' + super.toString() + ']'; } // extend, do not replace
}
const r = new Rect(3, 4);
console.log(r.area(), String(r)); // 12 '[rect: 12.00]'
r instanceof Rect; // true
r instanceof Shape; // true
Object.getPrototypeOf(r) === Rect.prototype; // true
// composition: share behaviour by holding an object, not by extending one
const withLogging = (target) => new Proxy(target, {
get(obj, prop) {
const value = Reflect.get(obj, prop);
return typeof value === 'function'
? (...args) => { console.log('call', String(prop)); return value.apply(obj, args); }
: value;
}
});
const logged = withLogging(new Rect(2, 2));
logged.area(); // logs 'call area', returns 4Use inheritance when the subtype genuinely is a kind of the parent and the parent's contract still holds. For everything else — sharing a cache, a logger, a formatter — pass the object in as a constructor parameter. Composition keeps the two pieces independently testable and avoids the fragile-base-class problem where a parent change silently breaks every subclass.
super(...)must run beforethisis touched in a subclass constructor.- Check capability with
typeof x.method === 'function'or a symbol, not by walking a class hierarchy. - Keep
instanceofchecks out of application logic where you can help it; they couple callers to a specific class.
FAQ
Are classes just syntax over prototypes?
extends wires up the prototype chain for you. The differences are real but narrow: class bodies are strict, calling without new throws, and declarations are not hoisted in a usable way.Do private fields work everywhere?
Related
Modules and project structure Iterators, generators, Map and Set
Last refreshed 2026-09-18.