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.stringify and spread see own enumerable properties only.
  • Prefer Object.create(null) for a plain lookup table when you do not want inherited names such as constructor in 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); }
MemberWhere it livesAccessible as
constructor body assignmentsEach instanceobj.label
MethodsClass.prototypeobj.method()
#private fields and methodsEach instance, unlocked only inside the classNot from outside, not via Object.keys
static membersThe constructor functionClass.member
get/setThe prototypeRead or assign like a plain property
Class field x = 1Each instance, set before the constructor bodyobj.x
⚠️
A method extracted from an object loses its receiver: 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 4

Use 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 before this is touched in a subclass constructor.
  • Check capability with typeof x.method === 'function' or a symbol, not by walking a class hierarchy.
  • Keep instanceof checks out of application logic where you can help it; they couple callers to a specific class.

FAQ

Are classes just syntax over prototypes?
Yes. Class methods are non-enumerable prototype properties and 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?
They are standard in all current browsers and Node versions. Transpilers may lower them to a WeakMap, which changes debugging output but not behaviour. Older build targets are the only reason to avoid them.

Modules and project structure Iterators, generators, Map and Set

Last refreshed 2026-09-18.