Polyglot
Languages Web (HTML / CSS / JS) oop
Web (HTML / CSS / JS) § oop

Classes and prototypes

JavaScript’s object model is prototype-based — every object admits a hidden link (the prototype) to another object that supplies missing properties. ES2015 introduced the class syntax — substantial sugar over prototypes, with extends for inheritance, super for parent access, static for class-level members, getters and setters via get/set, and private fields (#field, ES2022). Classes are constructors — invoked with new to create instances. The combination — class syntax for substantial OOP, prototype chain for inheritance, private fields for encapsulation, the substantial method/getter/setter/static surface — is the substance of JavaScript’s class-oriented surface.

Class declarations

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    greet() {
        return `Hello, I am ${this.name}`;
    }

    birthday() {
        this.age += 1;
    }
}

const alice = new Person("Alice", 30);
console.log(alice.greet());                        // "Hello, I am Alice"
alice.birthday();
console.log(alice.age);                            // 31

The class is sugar over the prototype mechanism — the principal feature.

Class fields

ES2022+ admits class field declarations:

class Counter {
    count = 0;                                     // public field
    #internal = "hidden";                          // private field

    static instances = 0;                          // static public
    static #key = "secret";                        // static private

    increment() {
        this.count++;
        Counter.instances++;
    }
}

The principal forms:

  • Public fieldsname = value;
  • Private fields#name = value; (must be declared in class body)
  • Static fieldsstatic name = value;
  • Static private fieldsstatic #name = value;
class Account {
    #balance = 0;                                  // private; #-prefix mandatory

    constructor(initialBalance) {
        this.#balance = initialBalance;
    }

    deposit(amount) {
        if (amount <= 0) throw new Error("Invalid amount");
        this.#balance += amount;
    }

    get balance() {
        return this.#balance;
    }
}

const a = new Account(100);
a.deposit(50);
console.log(a.balance);                            // 150
console.log(a.#balance);                           // SyntaxError

Methods

class Calculator {
    add(a, b) { return a + b; }
    subtract(a, b) { return a - b; }

    // Async method:
    async fetch(url) {
        return await fetch(url).then(r => r.json());
    }

    // Generator method:
    *range(start, stop) {
        for (let i = start; i < stop; i++) yield i;
    }
}

For static methods (called on the class, not an instance):

class MathUtils {
    static square(n) {
        return n ** 2;
    }

    static cube(n) {
        return n ** 3;
    }

    static get pi() {
        return 3.14159;
    }
}

MathUtils.square(5);                               // 25
MathUtils.pi;                                      // 3.14159

Getters and setters

class Temperature {
    #celsius = 0;

    get celsius() {
        return this.#celsius;
    }

    set celsius(value) {
        if (value < -273.15) throw new Error("Below absolute zero");
        this.#celsius = value;
    }

    get fahrenheit() {
        return this.#celsius * 9/5 + 32;
    }

    set fahrenheit(value) {
        this.#celsius = (value - 32) * 5/9;
    }
}

const t = new Temperature();
t.celsius = 25;
console.log(t.fahrenheit);                         // 77
t.fahrenheit = 100;
console.log(t.celsius);                            // 37.78

The getters and setters admit substantial computed-property access — look like properties but execute code.

Inheritance

class Animal {
    constructor(name) {
        this.name = name;
    }

    speak() {
        return "...";
    }

    describe() {
        return `${this.name} says ${this.speak()}`;
    }
}

class Dog extends Animal {
    constructor(name, breed) {
        super(name);                               // call parent constructor
        this.breed = breed;
    }

    speak() {
        return "Woof!";                            // override
    }
}

const rex = new Dog("Rex", "Labrador");
console.log(rex.speak());                          // "Woof!"
console.log(rex.describe());                       // "Rex says Woof!" (uses override)
console.log(rex.breed);                            // "Labrador"

// instanceof:
rex instanceof Dog;                                // true
rex instanceof Animal;                             // true

The super(...) call invokes the parent’s constructor; required in subclass constructors before accessing this.

For calling parent’s method:

class Cat extends Animal {
    speak() {
        return super.speak() + " (Meow)";
    }
}

static methods and inheritance

class Base {
    static factory() {
        return new this();                         // `this` is the class
    }
}

class Derived extends Base {
    constructor() {
        super();
        this.name = "derived";
    }
}

const d = Derived.factory();                       // creates a Derived
console.log(d.name);                               // "derived"

The this inside static methods refers to the class — admits substantial polymorphic factory patterns.

instanceof

class Animal {}
class Dog extends Animal {}

const d = new Dog();
d instanceof Dog;                                  // true
d instanceof Animal;                               // true
d instanceof Object;                               // true

// Custom Symbol.hasInstance:
class Even {
    static [Symbol.hasInstance](value) {
        return typeof value === "number" && value % 2 === 0;
    }
}

4 instanceof Even;                                 // true
3 instanceof Even;                                 // false

The Symbol.hasInstance admits substantial customisation of instanceof.

Prototype chain

Under the hood, classes use the prototype chain:

class Animal {
    speak() { return "..."; }
}

const a = new Animal();
Object.getPrototypeOf(a) === Animal.prototype;     // true
Object.getPrototypeOf(Animal.prototype) === Object.prototype;  // true

// Direct access to prototype:
Animal.prototype.speak;                            // function
a.__proto__;                                       // Animal.prototype (legacy)

For non-class prototype patterns:

function Person(name) {
    this.name = name;
}

Person.prototype.greet = function () {
    return `Hello, ${this.name}`;
};

const alice = new Person("Alice");
console.log(alice.greet());

// Or with Object.create:
const animal = { speak() { return "..."; } };
const dog = Object.create(animal);
dog.bark = function () { return "Woof!"; };
console.log(dog.speak());                          // inherited

The class syntax admits substantial readability over the raw prototype mechanism.

Mixins

For multiple-inheritance-like behaviour:

const Walking = {
    walk() { return `${this.name} walks`; }
};

const Swimming = {
    swim() { return `${this.name} swims`; }
};

class Duck {
    constructor(name) {
        this.name = name;
    }
}

Object.assign(Duck.prototype, Walking, Swimming);

const d = new Duck("Donald");
console.log(d.walk());                             // "Donald walks"
console.log(d.swim());                             // "Donald swims"

For substantial mixins, the conventional contemporary pattern uses higher-order classes:

const Walkable = (Base) => class extends Base {
    walk() { return `${this.name} walks`; }
};

const Swimmable = (Base) => class extends Base {
    swim() { return `${this.name} swims`; }
};

class Animal {
    constructor(name) { this.name = name; }
}

class Duck extends Walkable(Swimmable(Animal)) {}

const d = new Duck("Donald");
console.log(d.walk());                             // "Donald walks"
console.log(d.swim());                             // "Donald swims"

Common patterns

Builder

class HttpRequestBuilder {
    #url;
    #method = "GET";
    #headers = {};
    #body = null;

    setUrl(url) { this.#url = url; return this; }
    setMethod(method) { this.#method = method; return this; }
    setHeader(key, value) { this.#headers[key] = value; return this; }
    setBody(body) { this.#body = body; return this; }

    build() {
        return new Request(this.#url, {
            method: this.#method,
            headers: this.#headers,
            body: this.#body
        });
    }
}

const req = new HttpRequestBuilder()
    .setUrl("/api/users")
    .setMethod("POST")
    .setHeader("Content-Type", "application/json")
    .setBody(JSON.stringify(data))
    .build();

Singleton

class AppConfig {
    static #instance;

    constructor() {
        if (AppConfig.#instance) {
            return AppConfig.#instance;
        }
        this.host = "localhost";
        this.port = 8080;
        AppConfig.#instance = this;
    }
}

const a = new AppConfig();
const b = new AppConfig();
a === b;                                           // true

The conventional contemporary alternative is a module:

// config.js
const config = {
    host: "localhost",
    port: 8080
};
export default config;

// elsewhere:
import config from "./config.js";

Factory

class User {
    static fromForm(form) {
        return new User(
            form.elements.name.value,
            form.elements.email.value
        );
    }

    static fromJSON(json) {
        return new User(json.name, json.email);
    }

    constructor(name, email) {
        this.name = name;
        this.email = email;
    }
}

const user = User.fromForm(formElement);
const user2 = User.fromJSON(data);

Observer

class EventEmitter {
    #listeners = new Map();

    on(event, listener) {
        if (!this.#listeners.has(event)) {
            this.#listeners.set(event, []);
        }
        this.#listeners.get(event).push(listener);
        return () => this.off(event, listener);    // unsubscribe
    }

    off(event, listener) {
        const list = this.#listeners.get(event);
        if (list) {
            const idx = list.indexOf(listener);
            if (idx >= 0) list.splice(idx, 1);
        }
    }

    emit(event, ...args) {
        const list = this.#listeners.get(event);
        if (list) {
            list.forEach(l => l(...args));
        }
    }
}

const emitter = new EventEmitter();
const off = emitter.on("data", (value) => console.log("got", value));
emitter.emit("data", 42);
off();                                             // unsubscribe

Iterator protocol

class Range {
    constructor(start, stop, step = 1) {
        this.start = start;
        this.stop = stop;
        this.step = step;
    }

    [Symbol.iterator]() {
        let current = this.start;
        return {
            next: () => {
                if (current < this.stop) {
                    const value = current;
                    current += this.step;
                    return { value, done: false };
                }
                return { value: undefined, done: true };
            }
        };
    }
}

for (const i of new Range(1, 5)) {
    console.log(i);                                // 1, 2, 3, 4
}

const arr = [...new Range(1, 5)];                  // [1, 2, 3, 4]

For substantial cleaner code, use a generator method:

class Range {
    constructor(start, stop, step = 1) {
        this.start = start;
        this.stop = stop;
        this.step = step;
    }

    *[Symbol.iterator]() {
        for (let i = this.start; i < this.stop; i += this.step) {
            yield i;
        }
    }
}

Custom error

class ApiError extends Error {
    constructor(message, status, body) {
        super(message);
        this.name = "ApiError";
        this.status = status;
        this.body = body;
    }
}

try {
    throw new ApiError("Not found", 404, { error: "user not found" });
} catch (e) {
    if (e instanceof ApiError) {
        console.log(e.status, e.message);
    }
}

Abstract method (no native)

class Shape {
    constructor() {
        if (new.target === Shape) {
            throw new Error("Shape is abstract; subclass it");
        }
    }

    area() {
        throw new Error("area() must be implemented by subclass");
    }
}

class Circle extends Shape {
    constructor(radius) {
        super();
        this.radius = radius;
    }

    area() {
        return Math.PI * this.radius ** 2;
    }
}

new Circle(5).area();                              // 78.54...
new Shape();                                       // ERROR

toString / valueOf

class Money {
    constructor(amount, currency) {
        this.amount = amount;
        this.currency = currency;
    }

    toString() {
        return `${this.amount} ${this.currency}`;
    }

    valueOf() {
        return this.amount;
    }
}

const m = new Money(100, "USD");
console.log(`Price: ${m}`);                        // "Price: 100 USD"
console.log(m + 50);                               // 150 (uses valueOf)

Symbol.toPrimitive

class Distance {
    constructor(meters) {
        this.meters = meters;
    }

    [Symbol.toPrimitive](hint) {
        if (hint === "number") return this.meters;
        if (hint === "string") return `${this.meters}m`;
        return `${this.meters}m`;                  // default
    }
}

const d = new Distance(100);
+d;                                                // 100 (number coercion)
`${d}`;                                            // "100m" (string)
d + 50;                                            // 150 (default → number)

Composition over inheritance

// Inheritance:
class Parent { ... }
class Child extends Parent { ... }

// Composition:
class Engine { start() { ... } }
class Wheels { rotate() { ... } }

class Car {
    constructor() {
        this.engine = new Engine();
        this.wheels = new Wheels();
    }

    start() { this.engine.start(); }
    drive() { this.wheels.rotate(); }
}

The conventional contemporary discipline favours composition for substantial flexibility.

class with Symbol

const PRIVATE_DATA = Symbol("private");

class Account {
    constructor(initialBalance) {
        this[PRIVATE_DATA] = { balance: initialBalance };
    }

    deposit(amount) {
        this[PRIVATE_DATA].balance += amount;
    }

    get balance() {
        return this[PRIVATE_DATA].balance;
    }
}

The Symbol-based “privacy” is conventional pre-# field syntax; modern code uses #.

Static factory for tagged classes

class Result {
    constructor(success, value) {
        this.success = success;
        this.value = value;
    }

    static ok(value) {
        return new Result(true, value);
    }

    static err(error) {
        return new Result(false, error);
    }
}

const result = Result.ok(42);
const failed = Result.err(new Error("failed"));

Method chaining

class StringBuilder {
    #parts = [];

    append(s) {
        this.#parts.push(s);
        return this;
    }

    appendLine(s) {
        return this.append(s + "\n");
    }

    build() {
        return this.#parts.join("");
    }
}

const text = new StringBuilder()
    .append("Hello, ")
    .append("World!")
    .appendLine("")
    .append("Goodbye.")
    .build();

A note on the prototype mechanism

Under the hood, class is sugar for the prototype chain:

class Foo {
    bar() { return "bar"; }
}

// Equivalent (approximately):
function Foo() {}
Foo.prototype.bar = function () { return "bar"; };

// Either way:
const f = new Foo();
f.bar();                                           // "bar"
Object.getPrototypeOf(f) === Foo.prototype;        // true

The conventional discipline uses class — admit substantial readability and substantial standard-library support.

A note on the conventional discipline

The contemporary JavaScript classes advice:

  • Use class syntax over the raw prototype mechanism.
  • Use # private fields over Symbol-based or convention-based privacy.
  • Use class fields over assigning in constructor (where appropriate).
  • Use arrow-as-field for methods needing lexical this.
  • Use static methods for factory and utility patterns.
  • Use extends and super for inheritance.
  • Use composition over substantial inheritance.
  • Use mixins via higher-order classes for substantial multi-inheritance-like patterns.
  • Use custom errors extending Error.
  • Implement Symbol.iterator for iterable classes.
  • Reach for TypeScript (treated separately) for substantial type safety.

The combination — class syntax over prototypes, public/private/static fields, getters and setters, inheritance with extends/super, the substantial instanceof check, the Symbol.iterator protocol, the higher-order-class mixin pattern — is the substance of JavaScript’s class-oriented surface. The discipline produces substantial encapsulated, well-organised code with substantial flexibility through the prototype-based foundation.