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 fields —
name = value; - Private fields —
#name = value;(must be declared in class body) - Static fields —
static name = value; - Static private fields —
static #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
classsyntax 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
staticmethods for factory and utility patterns. - Use
extendsandsuperfor 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.iteratorfor 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.