Skip to main content
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.

JavaScript classes, introduced in ES6, give the language a clean syntax for object-oriented patterns while preserving the flexible prototype system underneath. They’re the right tool when you need to create many instances with shared behavior, enforce encapsulation with private state, or model an “is-a” relationship through inheritance. Pair them with private fields, static members, and mixins for production-quality OOP.

Defining a Class

A class is a blueprint for creating objects. The constructor method runs when you call new ClassName() and is where you initialize per-instance state. Methods defined in the class body are placed on the prototype and shared across all instances — they’re not duplicated in memory for each object.

class Animal {
  // Instance field with a default value — no need to set in constructor
  alive = true;

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

  // Instance method — shared via the prototype
  describe() {
    return `${this.name} is a ${this.species}`;
  }

  // Override the built-in toString so template literals work naturally
  toString() {
    return `[Animal: ${this.name}]`;
  }
}

const cat = new Animal("Whiskers", "cat");
console.log(cat.describe());        // "Whiskers is a cat"
console.log(cat.alive);             // true
console.log(`${cat}`);              // "[Animal: Whiskers]"
console.log(cat instanceof Animal); // true

Inheritance with extends and super

Inheritance lets a subclass reuse and extend the behavior of a parent class, modeling “is-a” relationships like Dog is-an Animal. The extends keyword sets up the prototype chain, and super gives access to the parent class’s constructor and methods. You must call super() before accessing this in a subclass constructor — the parent needs to initialize itself first.

class Dog extends Animal {
  constructor(name, breed) {
    super(name, "dog"); // must call super before accessing this
    this.breed  = breed;
    this.tricks = [];
  }

  // Override parent method — calls super.describe() to reuse parent logic
  describe() {
    return `${super.describe()} (${this.breed})`;
  }

  learn(trick) {
    this.tricks.push(trick);
    return this; // return this to allow method chaining
  }

  perform() {
    if (this.tricks.length === 0) return `${this.name} doesn't know any tricks yet.`;
    return `${this.name} performs: ${this.tricks.join(", ")}`;
  }
}

const rex = new Dog("Rex", "German Shepherd");
rex.learn("sit").learn("shake").learn("roll over");

console.log(rex.describe()); // "Rex is a dog (German Shepherd)"
console.log(rex.perform());  // "Rex performs: sit, shake, roll over"
console.log(rex instanceof Dog);    // true
console.log(rex instanceof Animal); // true — prototype chain includes Animal

Multi-level hierarchy

class GuideDog extends Dog {
  constructor(name, breed, owner) {
    super(name, breed);
    this.owner      = owner;
    this.certified  = false;
  }

  certify() {
    this.certified = true;
    return this;
  }

  describe() {
    const cert = this.certified ? " [certified]" : "";
    return `${super.describe()}${cert}, guides ${this.owner}`;
  }
}

const buddy = new GuideDog("Buddy", "Labrador", "John");
buddy.certify().learn("navigate crosswalk");

console.log(buddy.describe());
// "Buddy is a dog (Labrador) [certified], guides John"

Private Fields

Private fields use a # prefix and are enforced by the JavaScript engine — accessing them outside the class is a SyntaxError, not just a runtime convention. This is true encapsulation: external code cannot read or modify the field, depend on it in tests, or accidentally break it. Use private fields for state that is an internal implementation detail and should be managed only through the class’s public API.

class BankAccount {
  #balance;                    // declared but not initialized
  #transactionLog = [];        // private field with default value

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

  deposit(amount) {
    if (amount <= 0) throw new RangeError("Deposit amount must be positive");
    this.#balance += amount;
    this.#transactionLog.push({ type: "deposit", amount, date: new Date() });
    return this; // allow chaining
  }

  withdraw(amount) {
    if (amount <= 0) throw new RangeError("Withdrawal amount must be positive");
    if (amount > this.#balance) throw new RangeError("Insufficient funds");
    this.#balance -= amount;
    this.#transactionLog.push({ type: "withdrawal", amount, date: new Date() });
    return this;
  }

  get balance() {
    return this.#balance; // read-only access through a getter
  }

  get history() {
    return [...this.#transactionLog]; // return a copy — never expose the internal array
  }
}

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

// account.#balance; // SyntaxError — truly inaccessible from outside

Getters and Setters

Getters and setters look like properties from the outside but run functions when accessed or assigned. This lets you compute derived values on read, validate on write, and change the internal representation later without breaking any code that uses the class. They’re the clean way to expose controlled access to private state.

class Temperature {
  #celsius;

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

  // Computed derived values — callers just read a property, not call a method
  get celsius()    { return this.#celsius; }
  get fahrenheit() { return this.#celsius * 9/5 + 32; }
  get kelvin()     { return this.#celsius + 273.15; }

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

  set fahrenheit(value) {
    this.celsius = (value - 32) * 5/9; // reuses the celsius setter's validation
  }
}

const temp = new Temperature(100);
console.log(temp.fahrenheit); // 212
console.log(temp.kelvin);     // 373.15
temp.fahrenheit = 32;         // setter converts and validates
console.log(temp.celsius);    // 0

Static Methods and Properties

Static members belong to the class itself rather than to any instance. They’re the right home for factory methods that create instances in non-standard ways, utility functions logically associated with the class, and class-level constants or caches. Calling a static method on an instance is a TypeError.

class Color {
  static #cache = new Map(); // private static — shared across all instances

  // Pre-built constants for common colors
  static RED   = new Color(255, 0, 0);
  static GREEN = new Color(0, 255, 0);
  static BLUE  = new Color(0, 0, 255);

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

  // Static factory — returns a cached instance to avoid duplicate objects
  static fromHex(hex) {
    if (Color.#cache.has(hex)) return Color.#cache.get(hex);
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);
    const color = new Color(r, g, b);
    Color.#cache.set(hex, color);
    return color;
  }

  // Static utility — operates on Color instances but doesn't need one to be called on
  static mix(a, b, ratio = 0.5) {
    return new Color(
      Math.round(a.r * (1 - ratio) + b.r * ratio),
      Math.round(a.g * (1 - ratio) + b.g * ratio),
      Math.round(a.b * (1 - ratio) + b.b * ratio),
    );
  }

  toHex() {
    return `#${[this.r, this.g, this.b].map(n => n.toString(16).padStart(2, "0")).join("")}`;
  }

  toString() {
    return `rgb(${this.r}, ${this.g}, ${this.b})`;
  }
}

const purple = Color.mix(Color.RED, Color.BLUE);
console.log(purple.toHex());   // "#7f007f"

const coral = Color.fromHex("#ff6b6b");
console.log(`${coral}`);       // "rgb(255, 107, 107)"

Mixins

JavaScript classes support only single inheritance — a class can have one parent. Mixins work around this limitation by composing independent behaviors onto a class without forcing an artificial hierarchy. A mixin is a function that takes a base class and returns a new class extending it with additional methods. You can apply as many mixins as you need.

// Mixin: add JSON serialization to any class
const Serializable = (Base) => class extends Base {
  serialize() {
    return JSON.stringify(this);
  }

  static deserialize(json) {
    return Object.assign(new this(), JSON.parse(json));
  }
};

// Mixin: add event emitter capability to any class
const EventEmitter = (Base) => class extends Base {
  #listeners = new Map();

  on(event, fn) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, []);
    this.#listeners.get(event).push(fn);
    return this;
  }

  emit(event, ...args) {
    (this.#listeners.get(event) ?? []).forEach(fn => fn(...args));
    return this;
  }

  off(event, fn) {
    const fns = this.#listeners.get(event) ?? [];
    this.#listeners.set(event, fns.filter(f => f !== fn));
    return this;
  }
};

// Mixin: add validation to any class
const Validatable = (Base) => class extends Base {
  #errors = [];

  get errors() { return [...this.#errors]; }
  get valid()  { return this.#errors.length === 0; }

  addError(field, message) {
    this.#errors.push({ field, message });
  }

  clearErrors() {
    this.#errors = [];
    return this;
  }
};

// Compose — apply multiple mixins to a base class
class Model {}

// Each mixin wraps the previous — the result extends all three
class UserModel extends Serializable(EventEmitter(Validatable(Model))) {
  constructor(data = {}) {
    super();
    this.id    = data.id    ?? null;
    this.name  = data.name  ?? "";
    this.email = data.email ?? "";
  }

  validate() {
    this.clearErrors();
    if (!this.name.trim())        this.addError("name",  "Name is required");
    if (!this.email.includes("@")) this.addError("email", "Valid email required");
    return this.valid;
  }

  save() {
    if (!this.validate()) {
      this.emit("validationFailed", this.errors);
      return false;
    }
    this.emit("saved", this);
    return true;
  }
}

const user = new UserModel({ name: "Alice", email: "[email protected]" });

user.on("saved", u => console.log(`Saved: ${u.name}`));
user.on("validationFailed", errs => console.log("Errors:", errs));

user.save();      // "Saved: Alice"

const bad = new UserModel({ name: "", email: "not-an-email" });
bad.save();       // "Errors: [{field: "name", ...}, {field: "email", ...}]"

console.log(user.serialize());
// '{"id":null,"name":"Alice","email":"[email protected]"}'

Common Pitfalls

Forgetting super() in a subclass constructor:

class Child extends Parent {
  constructor(value) {
    // this.value = value; // ReferenceError — `this` is not available before super()
    super();
    this.value = value; // correct — super() initializes `this` first
  }
}

Using arrow functions as class methods breaks inheritance:

class Base {
  greet = () => `Hello from Base`; // arrow — stored as an own property, not on prototype
}

class Child extends Base {
  greet = () => `Hello from Child`;
}

// super.greet() in Child won't work — arrow methods bypass the prototype chain
// Use method shorthand instead: greet() { ... }

Static methods are not available on instances:

class Foo {
  static create() { return new Foo(); }
}

const f = Foo.create(); // correct — called on the class
// f.create();          // TypeError — not on the instance

Private fields are not accessible in subclasses:

class A {
  #secret = 42;
  getSecret() { return this.#secret; } // only A's methods can read #secret
}

class B extends A {
  reveal() { return this.#secret; }    // SyntaxError — subclass has no access
  revealSafe() { return this.getSecret(); } // correct — go through the public API
}

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.