Skip to main content
JavaScript · lesson 12 of 24
JavaScript intermediate Lesson 12 of 24

Object-Oriented Programming in JavaScript

Learn ES6 classes, inheritance, private fields, static members, getters/setters, and the mixin pattern for composing behavior in JavaScript.

By SkillByExample · Updated

JavaScript’s class syntax gives you constructors, inheritance, private state and static members — all built on the prototype system underneath. Every example below is a complete file run with node.

Defining a Class

The constructor runs on new; fields set per-instance state; methods are shared by every instance.

class Animal {
  alive = true;

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

  describe() { return `${this.name} is a ${this.species}`; }
  toString() { return `[Animal: ${this.name}]`; }
}

const cat = new Animal("Whiskers", "cat");
const dog = new Animal("Rex", "dog");

console.log(cat.describe(), "|", `${cat}`);
console.log(cat);
console.log(Object.keys(cat));
console.log(cat.describe === dog.describe, typeof Animal);
try { Animal("oops"); } catch (err) { console.log(err.message); }
$ node class.js
Whiskers is a cat | [Animal: Whiskers]
Animal { alive: true, name: 'Whiskers', species: 'cat' }
[ 'alive', 'name', 'species' ]
true function
Class constructor Animal cannot be invoked without 'new'

alive, name and species are own properties of each instance, so they appear in Object.keys. describe and toString don’t: they live once on Animal.prototype, which is why cat.describe === dog.describe. A template literal calls toString() automatically. typeof Animal is "function" — a class is a special function that refuses to be called without new.

Inheritance with extends and super

extends makes a subclass; super(...) runs the parent constructor, and super.method() calls the parent’s version of an overridden method.

class Animal {
  constructor(name, species) { this.name = name; this.species = species; }
  describe() { return `${this.name} is a ${this.species}`; }
}

class Dog extends Animal {
  tricks = [];
  constructor(name, breed) {
    super(name, "dog");
    this.breed = breed;
  }
  describe() { return `${super.describe()} (${this.breed})`; }
  learn(trick) { this.tricks.push(trick); return this; }
}

class GuideDog extends Dog {
  constructor(name, breed, owner) { super(name, breed); this.owner = owner; }
  describe() { return `${super.describe()}, guides ${this.owner}`; }
}

const buddy = new GuideDog("Buddy", "Labrador", "John").learn("sit").learn("stop at kerb");
console.log(buddy.describe());
console.log(buddy.tricks);
console.log(buddy instanceof GuideDog, buddy instanceof Dog, buddy instanceof Animal);
console.log(buddy.constructor.name, Object.getPrototypeOf(GuideDog) === Dog);
$ node extends.js
Buddy is a dog (Labrador), guides John
[ 'sit', 'stop at kerb' ]
true true true
GuideDog true

Each describe adds its own part and delegates the rest upward through super.describe(), so the three-level result is assembled from all three classes. instanceof is true for every class in the chain. learn returns this, and because this is the GuideDog, chaining keeps working in subclasses.

super() Must Come First

In a subclass constructor, this doesn’t exist until super() has run.

class Parent {
  constructor() { this.createdBy = "Parent"; }
}

class Broken extends Parent {
  constructor(value) {
    this.value = value;
    super();
  }
}

class Fixed extends Parent {
  constructor(value) {
    super();
    this.value = value;
  }
}

try { new Broken(1); } catch (err) { console.log(err.name + ":", err.message); }
console.log(new Fixed(1));
$ node super.js
ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
Fixed { createdBy: 'Parent', value: 1 }

It’s the parent constructor that creates and initialises the object, so touching this before super() is a ReferenceError at runtime. Subclass field initialisers (like tricks = [] above) run right after super() returns.

Private Fields

A # prefix makes a field private — enforced by the language, not a naming convention.

class BankAccount {
  #balance;
  #log = [];

  constructor(owner, initialBalance = 0) {
    this.owner = owner;
    this.#balance = initialBalance;
  }
  deposit(amount) { return this.#apply("deposit", amount); }
  withdraw(amount) {
    if (amount > this.#balance) throw new RangeError(`Insufficient funds: ${this.#balance} available`);
    return this.#apply("withdraw", -amount);
  }
  #apply(type, delta) { this.#balance += delta; this.#log.push(`${type} ${Math.abs(delta)}`); return this; }
  get balance() { return this.#balance; }
  get history() { return [...this.#log]; }
}

const account = new BankAccount("Alice", 1000).deposit(500).withdraw(200);
console.log(account.balance, account.history);
console.log(account, Object.keys(account));

account.balance = 1_000_000;
account.history.push("forged");
console.log(account.balance, account.history.length);
try { account.withdraw(5000); } catch (err) { console.log(err.message); }
try { eval("account.#balance"); } catch (err) { console.log(err.name + ":", err.message); }
$ node private.js
1300 [ 'deposit 500', 'withdraw 200' ]
BankAccount { owner: 'Alice' } [ 'owner' ]
1300 2
Insufficient funds: 1300 available
SyntaxError: Private field '#balance' must be declared in an enclosing class

Private fields don’t show up in Object.keys, and console.log shows the object’s public face only. balance has a getter but no setter, so assigning to it silently did nothing (it would throw in strict mode). history returns a copy, so pushing into it didn’t touch the real log. And outside the class, account.#balance isn’t even valid syntax — the code never gets as far as running. Methods can be private too: #apply.

Getters and Setters

Accessors look like properties to callers but run code — to compute a value, or to validate one.

class Temperature {
  #celsius;
  constructor(celsius) { this.celsius = celsius; }

  get celsius() { return this.#celsius; }
  set celsius(value) {
    if (value < -273.15) throw new RangeError(`${value}°C is below absolute zero`);
    this.#celsius = value;
  }
  get fahrenheit() { return this.#celsius * 9 / 5 + 32; }
  set fahrenheit(value) { this.celsius = (value - 32) * 5 / 9; }
  get kelvin() { return this.#celsius + 273.15; }
}

const temp = new Temperature(100);
console.log(temp.fahrenheit, temp.kelvin);
temp.fahrenheit = 32;
console.log(temp.celsius);
temp.celsius = 21.5;
console.log(temp.fahrenheit, temp.kelvin);
try { temp.fahrenheit = -500; } catch (err) { console.log(err.message); }
try { new Temperature(-300); } catch (err) { console.log(err.message); }
$ node accessors.js
212 373.15
0
70.7 294.65
-295.55555555555554°C is below absolute zero
-300°C is below absolute zero

Only Celsius is stored; the other scales are computed on every read. Every write — including from the fahrenheit setter and the constructor — goes through the celsius setter, so the validation can’t be bypassed. The error message reports the converted value, with all the floating-point digits of −500 °F in Celsius — format with toFixed before showing such numbers to users.

Static Members

static members belong to the class itself — useful for factories, constants and shared caches.

class Color {
  static #cache = new Map();
  static RED = new Color(255, 0, 0);
  static BLUE = new Color(0, 0, 255);

  constructor(r, g, b) { Object.assign(this, { r, g, b }); }

  static fromHex(hex) {
    if (!Color.#cache.has(hex)) {
      const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
      Color.#cache.set(hex, new Color(r, g, b));
    }
    return Color.#cache.get(hex);
  }
  static mix(a, b, ratio = 0.5) {
    const m = (x, y) => Math.round(x * (1 - ratio) + y * ratio);
    return new Color(m(a.r, b.r), m(a.g, b.g), m(a.b, b.b));
  }
  toHex() { return "#" + [this.r, this.g, this.b].map((n) => n.toString(16).padStart(2, "0")).join(""); }
}

console.log(Color.mix(Color.RED, Color.BLUE).toHex());
console.log(Color.fromHex("#ff6b6b"), Color.fromHex("#ff6b6b") === Color.fromHex("#ff6b6b"));
console.log(typeof Color.RED.fromHex, typeof Color.RED.toHex);
$ node static.js
#800080
Color { r: 255, g: 107, b: 107 } true
undefined function

The 50/50 mix of red and blue is #800080: Math.round(127.5) rounds up to 128 = 0x80. fromHex returns the same object for the same hex because of the static cache. Instances can’t see static members (Color.RED.fromHex is undefined) — call them on the class.

Mixins

A class has one parent. A mixin — a function that takes a class and returns a subclass — lets you compose several independent behaviours anyway.

const Serializable = (Base) => class extends Base {
  serialize() { return JSON.stringify(this); }
};

const Validatable = (Base) => class extends Base {
  #errors = [];
  get errors() { return [...this.#errors]; }
  check(ok, field, message) { if (!ok) this.#errors.push(`${field}: ${message}`); return ok; }
};

class Model {}

class UserModel extends Serializable(Validatable(Model)) {
  constructor({ name = "", email = "" } = {}) { super(); Object.assign(this, { name, email }); }
  validate() {
    const a = this.check(this.name.trim() !== "", "name", "required");
    const b = this.check(this.email.includes("@"), "email", "must contain @");
    return a && b;
  }
}

const good = new UserModel({ name: "Alice", email: "alice@example.com" });
const bad = new UserModel({ email: "nope" });
console.log(good.validate(), good.serialize());
console.log(bad.validate(), bad.errors);

let proto = Object.getPrototypeOf(UserModel.prototype);
const chain = [];
while (proto !== Object.prototype) { chain.push(proto.constructor.name || "(anonymous)"); proto = Object.getPrototypeOf(proto); }
console.log(chain.join(" -> "));
$ node mixins.js
true {"name":"Alice","email":"alice@example.com"}
false [ 'name: required', 'email: must contain @' ]
(anonymous) -> (anonymous) -> Model

UserModel got serialize from one mixin and check/errors from the other. serialize output has no errors — private fields aren’t properties at all, so JSON.stringify never sees them. The chain printout shows how it works: each mixin inserted a real class into the prototype chain. The mixin classes print as (anonymous) because class expressions returned from arrow functions get no name — one reason stack traces through mixins are harder to read.

Pitfall: Arrow-Function Fields Don’t Inherit Like Methods

A field assigned an arrow function lives on each instance, not on the prototype — so super can’t reach it.

class Base {
  name = "base-instance";
  greetArrow = () => `Hello from ${this.name} (arrow)`;
  greetMethod() { return `Hello from ${this.name} (method)`; }
}

class Child extends Base {
  greetMethod() { return `${super.greetMethod()} + Child`; }
  greetArrowViaSuper() { return typeof super.greetArrow; }
}

const c = new Child();
console.log(c.greetMethod());
console.log(c.greetArrowViaSuper());
console.log(Object.keys(c));
const { greetArrow, greetMethod } = c;
console.log(greetArrow());
try { console.log(greetMethod()); } catch (err) { console.log(err.constructor.name + ":", err.message); }
$ node arrowfields.js
Hello from base-instance (method) + Child
undefined
[ 'name', 'greetArrow' ]
Hello from base-instance (arrow)
TypeError: Cannot read properties of undefined (reading 'name')

super.greetArrow is undefined because it isn’t on Base.prototype — it’s an own property of every instance (it shows in Object.keys), created once per object. The upside is the last two lines: a detached arrow field keeps its this, while a detached method loses it. Use methods by default; reach for arrow fields only for callbacks you’ll pass around, such as event handlers.

Key Takeaways

  • Methods live once on the prototype; fields are per instance.
  • In a subclass constructor, call super() before touching this.
  • #private fields and methods are enforced by the language, invisible to Object.keys and JSON.stringify.
  • Getters/setters let you validate and compute while keeping a property-like API.
  • static members belong to the class, not instances.
  • Mixins compose behaviour by inserting classes into the prototype chain.

Practice

1. Add a Cat subclass of Animal whose describe() adds whether it's an indoor cat.
class Animal {
  constructor(name, species) { this.name = name; this.species = species; }
  describe() { return `${this.name} is a ${this.species}`; }
}
class Cat extends Animal {
  constructor(name, indoor = true) { super(name, "cat"); this.indoor = indoor; }
  describe() { return `${super.describe()} (${this.indoor ? "indoor" : "outdoor"})`; }
}
console.log(new Cat("Whiskers").describe());
console.log(new Cat("Tom", false).describe());
$ node p1.js
Whiskers is a cat (indoor)
Tom is a cat (outdoor)
2. Give BankAccount a static count of how many accounts have been created.
class BankAccount {
  static #count = 0;
  static get count() { return BankAccount.#count; }
  #balance;
  constructor(balance) { this.#balance = balance; BankAccount.#count += 1; }
}
new BankAccount(10);
new BankAccount(20);
console.log(BankAccount.count);
BankAccount.count = 99;
console.log(BankAccount.count);
$ node p2.js
2
2

A private static field with only a static getter makes the count readable but not writable from outside.

3. Write a Timestamped mixin that records createdAt in the constructor, and apply it to a class.
const Timestamped = (Base) => class extends Base {
  createdAt = new Date("2026-01-01T09:00:00Z");
};

class Note { constructor(text) { this.text = text; } }
class StampedNote extends Timestamped(Note) {}

const n = new StampedNote("buy milk");
console.log(n.text, n.createdAt.toISOString());
console.log(n instanceof Note);
$ node p3.js
buy milk 2026-01-01T09:00:00.000Z
true

The fixed date keeps the output reproducible; in real code use new Date(). The mixin passes constructor arguments through automatically because it declares no constructor of its own.

4. Add a set kelvin accessor to Temperature that reuses the Celsius validation.
class Temperature {
  #celsius = 0;
  get celsius() { return this.#celsius; }
  set celsius(v) { if (v < -273.15) throw new RangeError("below absolute zero"); this.#celsius = v; }
  set kelvin(k) { this.celsius = k - 273.15; }
}
const t = new Temperature();
t.kelvin = 300;
console.log(t.celsius.toFixed(2));
try { t.kelvin = -1; } catch (err) { console.log(err.message); }
$ node p4.js
26.85
below absolute zero

Routing the write through this.celsius means there is exactly one place that enforces the rule.

Frequently Asked Questions

Are JavaScript classes just syntactic sugar over prototypes?
Yes, under the hood ES6 classes still use prototype-based inheritance. The class syntax is cleaner and adds features like private fields and static blocks, but the runtime model is the same prototype chain.
What are private fields and why use them?
Private fields (prefixed with #) are truly inaccessible outside the class — unlike the old convention of prefixing with an underscore, which was just a hint to developers. Use them to enforce encapsulation and prevent external code from depending on internal implementation details.
When should I use mixins instead of inheritance?
Use mixins when you need to share behavior across classes that don't share a logical parent. JavaScript only supports single inheritance, so mixins let you compose multiple independent capabilities (e.g. Serializable, Loggable) onto any class without forcing an artificial hierarchy.