Encapsulation & Private Fields

intermediate encapsulation private-fields WeakMap closures information-hiding

Encapsulation means hiding the internal details of an object and only exposing what’s necessary. Think of it like a car — we use the steering wheel and pedals (public interface) without needing to know how the engine works internally (private details).

In many languages, we get private, protected, and public keywords. JavaScript didn’t have real privacy for a long time, so developers came up with creative workarounds. Now, with ES2022, we finally have native private fields using the # syntax.

The # private fields (ES2022)

This is the modern, official way to create truly private properties. We prefix the name with #:

class BankAccount {
  #balance = 0;        // private field

  constructor(initial) {
    this.#balance = initial;
  }

  deposit(amount) {
    this.#balance += amount;
  }

  getBalance() {
    return this.#balance; // accessible inside the class
  }
}

const acc = new BankAccount(100);
acc.deposit(50);
console.log(acc.getBalance()); // 150
// console.log(acc.#balance);  // SyntaxError! Can't access from outside

The # is part of the field name — #balance and balance are two completely different properties. Trying to access #balance from outside the class body is a SyntaxError, not a runtime error. The privacy is enforced by the engine itself.

Private methods

We can make methods private too:

class User {
  #password;

  constructor(name, password) {
    this.name = name;
    this.#password = password;
  }

  #encrypt(value) {         // private method
    return btoa(value);     // simple encoding for demo
  }

  getEncryptedPassword() {
    return this.#encrypt(this.#password);
  }
}

const u = new User("Manish", "secret123");
console.log(u.getEncryptedPassword()); // "c2VjcmV0MTIz"
// u.#encrypt("test");  // SyntaxError!

Private static fields

Static fields can be private too — useful for things like instance counts or shared secrets:

class Connection {
  static #maxConnections = 5;
  static #activeCount = 0;

  constructor() {
    if (Connection.#activeCount >= Connection.#maxConnections) {
      throw new Error("Too many connections");
    }
    Connection.#activeCount++;
  }

  static getActiveCount() {
    return Connection.#activeCount;
  }
}

Closure-based privacy (pre-ES2022)

Before # fields existed, closures were the go-to pattern for privacy. The idea is that variables declared inside a function are only accessible within that function’s scope:

function createUser(name, password) {
  // password is truly private — it's a closure variable
  return {
    name,
    checkPassword(input) {
      return input === password;
    }
  };
}

const user = createUser("Manish", "secret");
console.log(user.name);              // "Manish"
console.log(user.checkPassword("secret")); // true
console.log(user.password);          // undefined — not accessible

This still works great, but the downside is that we can’t use it with class syntax, and methods aren’t shared on the prototype (each instance gets its own copy).

WeakMap for private data

Another pre-ES2022 pattern uses a WeakMap to store private data keyed by the instance:

const _data = new WeakMap();

class User {
  constructor(name, secret) {
    _data.set(this, { secret }); // store private data
    this.name = name;
  }

  getSecret() {
    return _data.get(this).secret;
  }
}

const u = new User("Manish", "hidden");
console.log(u.getSecret()); // "hidden"
console.log(u.secret);      // undefined

The WeakMap is defined outside the class, so it’s not accessible to consumers. And because it’s a weak map, the entries are garbage-collected when the instance is garbage-collected. Clever, but now that we have # fields, there’s rarely a reason to use this pattern.

The _underscore convention

This is the oldest approach — just prefix the property with an underscore to signal “hey, don’t touch this from outside”:

class Config {
  constructor() {
    this._apiKey = "abc123"; // convention: treat as private
  }
}

But it’s not real privacy. Anyone can still access config._apiKey. It’s just a naming convention, a gentleman’s agreement. We see it a lot in older codebases and some libraries.

Symbols as pseudo-private keys

Using Symbol as property keys makes properties hard to access accidentally (they won’t show up in Object.keys or for...in), but they’re not truly private — someone can still find them with Object.getOwnPropertySymbols():

const _secret = Symbol("secret");

class Vault {
  constructor(value) {
    this[_secret] = value;
  }

  reveal() {
    return this[_secret];
  }
}

const v = new Vault("treasure");
console.log(v.reveal()); // "treasure"
// console.log(v[_secret]); // only works if you have the Symbol reference

Comparison of approaches

Approach
True Privacy?
Works with class?
# private fields
Yes (engine-enforced)
Yes
Closures
Yes
No (factory only)
WeakMap
Yes
Yes
_underscore
No (convention only)
Yes
Symbol keys
Pseudo-private
Yes

In simple language, encapsulation is about hiding the stuff that the outside world doesn’t need to see. In modern JavaScript, use # private fields — they’re simple, enforced by the engine, and work perfectly with classes. The older patterns (closures, WeakMap, underscores, Symbols) are good to know for reading legacy code, but for new code, # is the way to go.