Encapsulation & Abstraction

Difficulty: Intermediate

Encapsulation is the principle of bundling data (properties) and the methods that operate on that data into a single unit (an object or class), while restricting direct access to some of the object's internals. The goal is to prevent external code from depending on or accidentally modifying internal implementation details. In JavaScript, encapsulation has evolved through multiple mechanisms: naming conventions, closures, symbols, WeakMaps, and most recently, true private fields with the # syntax.

The modern approach to encapsulation in JavaScript uses private fields and methods, declared with the # prefix. These are enforced at the language level - attempting to access a #field from outside the class body results in a SyntaxError at parse time, not a runtime error. This is a significant improvement over the underscore convention (_property), which is merely a signal to other developers. Public properties are the interface; private fields are the implementation.

Before ES2022 private fields existed, closures were the primary way to achieve true data hiding in JavaScript. By defining variables inside a constructor function's scope and exposing only specific methods (the closure) as properties, you could create objects with genuinely inaccessible internal state. This pattern is sometimes called the revealing module pattern when applied to single objects or IIFEs. While closure-based privacy works, it has a cost: every instance gets its own copy of the methods, unlike prototype methods which are shared.

Getters and setters provide controlled access to an object's internal state. Instead of exposing a raw property that anyone can read or write to, you define accessor methods that can validate inputs, compute derived values, log access, or trigger side effects. In classes, they are defined with the get and set keywords and are accessed like regular properties. This is a key part of encapsulation because it lets you change the internal representation without breaking external code that depends on the property.

Abstraction in JavaScript is about hiding complexity and exposing only what is necessary. JavaScript does not have a formal abstract keyword like Java or C#, but you can simulate abstract classes by throwing errors in base class methods that subclasses are expected to override. This pattern ensures that subclasses provide their own implementation. The combination of encapsulation (hide how it works) and abstraction (hide that it works at all) creates clean interfaces that are easy to use and hard to misuse.

A practical example of encapsulation and abstraction together is a user authentication class. External code calls authenticate(password) and gets back a boolean. Internally, the class hashes the password, compares hashes, manages attempt counters, and enforces lockout policies. None of these details leak out. If you later change from bcrypt to argon2, the external interface remains unchanged. This is the power of information hiding - implementation changes do not break consumers.

Code examples

Private fields for encapsulation

class UserAccount {
  #passwordHash;
  #loginAttempts;
  #locked;

  constructor(username, passwordHash) {
    this.username = username;          // public
    this.#passwordHash = passwordHash; // private
    this.#loginAttempts = 0;
    this.#locked = false;
  }

  authenticate(inputHash) {
    if (this.#locked) {
      return { success: false, message: 'Account locked' };
    }

    if (inputHash === this.#passwordHash) {
      this.#loginAttempts = 0;
      return { success: true, message: 'Login successful' };
    }

    this.#loginAttempts++;
    if (this.#loginAttempts >= 3) {
      this.#locked = true;
      return { success: false, message: 'Account locked after 3 failed attempts' };
    }

    return { success: false, message: `Invalid password. ${3 - this.#loginAttempts} attempts remaining` };
  }

  get isLocked() {
    return this.#locked;
  }
}

const user = new UserAccount('alice', 'hash123');
console.log(user.authenticate('wrong'));
console.log(user.authenticate('wrong'));
console.log(user.authenticate('wrong'));
console.log(user.isLocked);
console.log(user.authenticate('hash123'));

The password hash, attempt counter, and lock state are all private. External code cannot read the password, reset attempts, or unlock the account - it can only interact through the authenticate method and isLocked getter. This is encapsulation in practice.

Closures for data hiding (pre-ES2022)

function createCounter(initial = 0) {
  let count = initial;     // truly private - in closure scope
  const history = [];      // truly private

  return {
    increment() {
      count++;
      history.push({ action: 'inc', value: count });
      return count;
    },
    decrement() {
      count--;
      history.push({ action: 'dec', value: count });
      return count;
    },
    getCount() {
      return count;
    },
    getHistory() {
      return [...history];  // return copy to prevent mutation
    },
    reset() {
      count = initial;
      history.length = 0;
      return count;
    }
  };
}

const counter = createCounter(10);
console.log(counter.increment());
console.log(counter.increment());
console.log(counter.decrement());
console.log(counter.getHistory());
console.log(counter.getCount());

// count and history are NOT accessible
console.log(counter.count);
console.log(counter.history);

The count and history variables live in the closure created by createCounter. The returned object's methods close over them, providing controlled access. Directly accessing counter.count returns undefined because it is not a property of the returned object - it is a local variable in the enclosing scope.

Getters and setters for controlled access

class Product {
  #price;
  #discount;
  #name;

  constructor(name, price) {
    this.#name = name;
    this.#price = price;
    this.#discount = 0;
  }

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

  set name(value) {
    if (typeof value !== 'string' || value.trim().length === 0) {
      throw new Error('Name must be a non-empty string');
    }
    this.#name = value.trim();
  }

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

  set price(value) {
    if (typeof value !== 'number' || value < 0) {
      throw new Error('Price must be a non-negative number');
    }
    this.#price = value;
  }

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

  set discount(percent) {
    if (percent < 0 || percent > 100) {
      throw new Error('Discount must be between 0 and 100');
    }
    this.#discount = percent;
  }

  // Computed property - read-only
  get finalPrice() {
    return this.#price * (1 - this.#discount / 100);
  }

  toString() {
    return `${this.#name}: ${this.finalPrice.toFixed(2)} (${this.#discount}% off)`;
  }
}

const item = new Product('Laptop', 999);
console.log(item.finalPrice);

item.discount = 20;
console.log(item.toString());

item.name = 'Gaming Laptop';
item.price = 1499;
item.discount = 15;
console.log(item.toString());

try {
  item.price = -100;
} catch (e) {
  console.log(e.message);
}

Every property is guarded by setters that validate input. The finalPrice getter computes a derived value without storing it separately. If the internal pricing formula changes, external code continues to work because it only accesses finalPrice.

Abstract class pattern and information hiding

// Abstract base class pattern
class DataStore {
  constructor(name) {
    if (new.target === DataStore) {
      throw new Error('DataStore is abstract - cannot instantiate directly');
    }
    this.name = name;
  }

  // Abstract methods - subclasses MUST override
  save(key, value) {
    throw new Error('save() must be implemented by subclass');
  }

  load(key) {
    throw new Error('load() must be implemented by subclass');
  }

  // Concrete method using abstract methods
  saveAll(entries) {
    const results = [];
    for (const [key, value] of Object.entries(entries)) {
      this.save(key, value);
      results.push(key);
    }
    return `Saved ${results.length} entries to ${this.name}`;
  }
}

class MemoryStore extends DataStore {
  #data = new Map();

  constructor() {
    super('MemoryStore');
  }

  save(key, value) {
    this.#data.set(key, value);
  }

  load(key) {
    return this.#data.get(key);
  }

  get size() {
    return this.#data.size;
  }
}

// Cannot instantiate abstract class
try {
  new DataStore('test');
} catch (e) {
  console.log(e.message);
}

const store = new MemoryStore();
console.log(store.saveAll({ a: 1, b: 2, c: 3 }));
console.log(store.load('b'));
console.log(store.size);
console.log(store instanceof DataStore);

DataStore simulates an abstract class by throwing if instantiated directly (using new.target). Its save() and load() methods throw errors to force subclasses to provide implementations. The concrete saveAll() method works with any subclass that implements the abstract interface. MemoryStore hides its internal Map behind private fields.

Key points

Concepts covered

encapsulation, private fields, closures for data hiding, getters setters, abstraction, information hiding