Encapsulation and OOP Concepts

Understand the four classic object-oriented pillars — abstraction, encapsulation, inheritance, and polymorphism — how JavaScript's private fields enforce real encapsulation, and when composition is a better fit than inheritance.

Object-oriented programming is usually described through four core ideas. JavaScript's classes support all four, but — unlike some languages — true encapsulation only became fully enforceable with the arrival of private class fields (#field).

Abstraction

Abstraction means exposing only the relevant, simplified interface of something while hiding the complicated details of how it works internally. When you call array.sort(), you don't need to know which sorting algorithm runs underneath — you just need to know it sorts the array. A well-designed class's public methods are its abstraction: a simple surface over potentially complex internal logic.

Encapsulation and JavaScript's # private fields

Encapsulation means bundling data together with the methods that operate on it, and restricting direct outside access to that data so it can only be changed through controlled, intentional methods. This prevents external code from putting an object into an invalid state.

Historically, JavaScript had no true private fields — developers used a leading underscore (_balance) purely as a naming convention to signal 'please don't touch this directly,' but nothing actually enforced it; outside code could still read or overwrite _balance freely. ES2022 introduced real private fields, prefixed with # (e.g. #balance), which are genuinely inaccessible from outside the class — attempting instance.#balance from outside throws a SyntaxError, not just a convention violation.

Private fields (and private methods, also prefixed with #) can only be accessed from inside the class body that declares them — not even subclasses can access a parent class's #private fields directly, which is different from some other object-oriented languages.

Inheritance (recap)

Inheritance, covered in depth in the previous two lessons, lets one class (a subclass) reuse and extend the behavior of another (a parent/base class) via extends and super, avoiding duplicated code between closely related types.

Polymorphism

Polymorphism ('many forms') means that different classes can implement the same method name in their own way, and calling that method on any of them 'just works' correctly for whatever the actual object is — without the calling code needing to know or check which specific class it's dealing with.

In the previous lesson's Shape/Circle/Square example, describe() called this.area() without knowing or caring whether this was a Circle or a Square — each subclass's own area() ran automatically. That is polymorphism in action, and it's what allows code like shapes.map(s => s.describe()) to work uniformly over a mixed array of different shape types.

Composition vs inheritance

Inheritance models an 'is-a' relationship (a Dog IS AN Animal). Composition models a 'has-a' relationship: instead of extending a base class, an object is built by combining smaller, focused objects or functions together as properties or via mixins.

Deep inheritance hierarchies (Animal -> Mammal -> Pet -> Dog -> ServiceDog) can become rigid and fragile — a change to a base class can ripple unpredictably through many subclasses, and some behaviors don't cleanly fit a single-parent chain (can a 'FlyingFish' extend both 'Fish' and 'Flyer'? JavaScript classes only support single inheritance). Composition avoids this by assembling behavior from independent, swappable pieces: 'prefer composition over inheritance' is a well-known design guideline for exactly this reason.

A common pattern is to compose behavior via plain functions or objects that get combined onto a class instance, rather than forcing every variation into one rigid inheritance tree.

Quick reference

Private fields must be declared in the class body (e.g. #balance = 0;) before they can be used anywhere in that class, including the constructor. You cannot dynamically create a private field like you can a regular property — obj.#name only works for fields explicitly declared in the class. Composition (mixing behaviors together) sidesteps the single-inheritance limitation of extends, letting an object combine multiple independent capabilities.

// encapsulation with real private fields (ES2022)
class BankAccount {
  #balance = 0;              // private field — inaccessible outside the class

  constructor(owner) {
    this.owner = owner;      // public field
  }

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

  #logTransaction(type, amount) {  // private method
    console.log(`${type}: ${amount}`);
  }

  get balance() {
    return this.#balance;    // controlled, read-only public access
  }
}

const acct = new BankAccount("Lee");
acct.deposit(100);
console.log(acct.balance);   // 100
// acct.#balance;             // SyntaxError: private field outside class

// composition instead of inheritance
const canFly = (state) => ({
  fly: () => `${state.name} is flying!`
});
const canSwim = (state) => ({
  swim: () => `${state.name} is swimming!`
});

function createDuck(name) {
  const state = { name };
  return { ...state, ...canFly(state), ...canSwim(state) };
}

const duck = createDuck("Donald");
console.log(duck.fly());
console.log(duck.swim());

Code Examples

The #temperature field can only be changed through the setter, which validates the range — this is encapsulation working as intended: outside code cannot bypass the validation logic because it cannot reach #temperature directly at all. Expected result: 22 Temperature must be between 10 and 30.

This demonstrates abstraction (Shape defines a simple describe() interface without exposing how area is calculated) and polymorphism (each subclass's own area() implementation runs automatically, letting one loop treat every shape uniformly). Expected result: This shape's area is 12. This shape's area is 12.57.

throw new Error(...) in the base area() acts as an abstract-method guard, forcing every subclass to provide its own implementation.

This is a mixin: withLogging is a function that takes a base class and returns a new class extending it, layering extra behavior on top without permanently baking logging into Model itself. Mixins are one way JavaScript approximates composing multiple independent behaviors despite only supporting single inheritance via extends. Expected result: Saving User... saved to database class User extends withLogging(Model) {} composes logging behavior onto Model dynamically, rather than hardcoding it into a rigid inheritance chain.

class Thermostat {
  #temperature = 20;

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

  set temperature(value) {
    if (value < 10 || value > 30) {
      throw new Error("Temperature must be between 10 and 30.");
    }
    this.#temperature = value;
  }
}

const t = new Thermostat();
t.temperature = 22;
console.log(t.temperature);
try {
  t.temperature = 100;
} catch (err) {
  console.log(err.message);
}
class Shape {
  area() {
    throw new Error("area() must be implemented by a subclass.");
  }
  describe() {
    return `This shape's area is ${this.area()}.`;
  }
}

class Triangle extends Shape {
  constructor(base, height) {
    super();
    this.base = base;
    this.height = height;
  }
  area() {
    return 0.5 * this.base * this.height;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }
  area() {
    return Number((Math.PI * this.radius ** 2).toFixed(2));
  }
}

const shapes = [new Triangle(6, 4), new Circle(2)];
shapes.forEach(shape => console.log(shape.describe()));
const withLogging = (Base) => class extends Base {
  save() {
    console.log(`Saving ${this.constructor.name}...`);
    return super.save();
  }
};

class Model {
  save() {
    return "saved to database";
  }
}

class User extends withLogging(Model) {}

const user = new User();
console.log(user.save());

Inheritance (is-a) vs composition (has-a)

Inheritance works well for a true is-a relationship with one clear ancestor. Composition works better when behavior needs to mix and match independently (a Duck flies and swims, but a Penguin swims without flying) — something single inheritance handles awkwardly.

INHERITANCE (is-a)              COMPOSITION (has-a)

     Animal                      Duck
       |                          |
   [extends]                [combines pieces]
       |                     /          \
     Duck                 flies()      swims()
 (rigid, single chain)   (independent, mix-and-match capabilities)

Important Rules

Abstraction hides implementation complexity behind a simple, intentional public interface.

Encapsulation restricts direct access to internal data — JavaScript's #field syntax is what makes this genuinely enforced, not just conventional.

Polymorphism lets different classes respond to the same method call in their own way, so calling code doesn't need to check types.

Prefer composition over deep inheritance when behavior needs to be mixed and matched rather than forced into one rigid ancestor chain.

Where this shows up

Private fields are now standard in production JavaScript classes for genuinely internal state (caches, validation flags, connection handles). Polymorphism underlies plugin systems and UI component libraries, where many different component classes implement a shared render() method. Composition over inheritance is a guiding principle in React (favoring hooks and composed components over class hierarchies) and in most modern JavaScript API design generally.

class Account { constructor() { this._balance = 0; } // underscore convention only } const a = new Account(); a._balance = -9999; // nothing stops this class Account { #balance = 0; deposit(amount) { if (amount > 0) this.#balance += amount; } get balance() { return this.#balance; } }

A leading underscore is only a naming convention — it does not stop outside code from reading or overwriting the property directly. Real encapsulation requires an actual # private field, which the language itself enforces.

class Bird extends Animal {} class FlyingFish extends Fish, Flyer {} // trying to inherit two base classes class FlyingFish extends Fish { constructor() { super(); Object.assign(this, createFlyer()); } } // or use a mixin function

JavaScript classes only support single inheritance — extends can only name one base class. Combining multiple independent behaviors requires composition or mixins, not multiple inheritance, which the language does not support directly.

[Multiple Choice] Which OOP concept is demonstrated when several different subclasses each implement their own version of a method, and calling code invokes it without checking which subclass it is? A. Encapsulation B. Abstraction C. Polymorphism D. Composition

C. Polymorphism — This is polymorphism: the same method call produces behavior appropriate to whichever concrete class the object actually is, without the caller needing to know or check the specific type.

[True / False] A property named with a leading underscore, like _balance, is truly private and cannot be accessed from outside its class. A. True B. False

B. False — False. A leading underscore is only a naming convention signaling 'internal use intended' — it is still a fully accessible, ordinary public property. Real, enforced privacy requires the # private field syntax.

Design a Vehicle base class with an abstract-style move() method that throws if not overridden, and a private #fuel field with a refuel() method. Then create a Car subclass that overrides move() to return a string using this.constructor.name, and demonstrate that #fuel cannot be accessed from outside the class.

class Vehicle { #fuel = 0; move() { throw new Error("move() must be implemented by a subclass."); } refuel(amount) { this.#fuel += amount; } get fuel() { return this.#fuel; } } class Car extends Vehicle { move() { return ${this.constructor.name} drives down the road.; } } const car = new Car(); car.refuel(20); console.log(car.move()); console.log(car.fuel); // 20, via the inherited getter // car.#fuel would be a SyntaxError — not accessible outside Vehicle's class body

Declare #fuel = 0; in Vehicle, and note that Car (a subclass) still cannot access Vehicle's #fuel directly — only code inside Vehicle's own class body can.