Skip to content

Private and Protected Properties and Methods in JavaScript

How to implement true encapsulation in JavaScript using ES2022 private fields (#) and the underscore convention (_) for protected members, with practical examples.

One of the core principles of object-oriented programming is encapsulation: hiding internal implementation details and exposing only a controlled public interface. Without encapsulation, any code can reach into an object and modify its internals, leading to unpredictable behavior and fragile systems.

JavaScript historically lacked true privacy for object properties. Developers relied on naming conventions (the underscore prefix _) to signal "do not touch." This worked through discipline but offered no enforcement. Since ES2022, JavaScript has true private fields and methods using the # syntax, providing real, language-enforced privacy that cannot be bypassed.

This guide covers both the convention-based "protected" pattern and the hard-private # syntax, showing you how to encapsulate state effectively in modern JavaScript classes.

The Convention: _protectedProp (Underscore Prefix)

Before JavaScript had real private fields, the community adopted a convention: prefix properties and methods with an underscore (_) to indicate they are internal and should not be accessed from outside the class.

The Convention in Practice

class BankAccount {
  constructor(owner, balance) {
    this.owner = owner;
    this._balance = balance; // "_" signals: treat as internal
    this._transactionLog = [];
  }

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

  withdraw(amount) {
    if (amount <= 0) {
      throw new Error("Withdrawal amount must be positive");
    }
    if (amount > this._balance) {
      throw new Error("Insufficient funds");
    }
    this._balance -= amount;
    this._logTransaction("withdrawal", amount);
  }

  getBalance() {
    return this._balance;
  }

  // "_" prefix: this is an internal helper method
  _logTransaction(type, amount) {
    this._transactionLog.push({
      type,
      amount,
      date: new Date(),
      balance: this._balance
    });
  }
}

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

The Problem: It Is Just a Convention

The underscore prefix is nothing more than a naming pattern. JavaScript does not enforce it in any way. Anyone can still access and modify _balance directly:

const account = new BankAccount("Alice", 1000);

// The convention says "don't do this," but nothing stops you
console.log(account._balance);       // 1000 (fully accessible)
account._balance = 999999;           // No error, no protection
console.log(account.getBalance());   // 999999 (state corrupted!)

// Can also call "internal" methods
account._logTransaction("hack", 0);  // Works fine

// Can enumerate and see all "private" properties
console.log(Object.keys(account));
// ['owner', '_balance', '_transactionLog']

The underscore convention relies entirely on developer discipline. It communicates intent ("this is internal") but provides no actual protection.

When the Convention Still Makes Sense

Despite its limitations, the underscore convention is not obsolete. It is useful when:

  • You want "protected" visibility where subclasses can access the property (true # private fields are not accessible in subclasses)
  • You are working in a codebase that predates ES2022 private fields
  • You want to signal "internal but not truly hidden" to other developers
  • You are writing library code where you want to give advanced users access to internals while signaling that the API is unstable
class Component {
  constructor(element) {
    this._element = element; // Subclasses need access to this
    this._state = {};
  }

  _setState(newState) {
    // Subclasses will call this
    this._state = { ...this._state, ...newState };
    this._render();
  }

  _render() {
    // Subclasses override this
  }
}

class Counter extends Component {
  constructor(element) {
    super(element);
    this._setState({ count: 0 }); // Accessing parent's "protected" method
  }

  increment() {
    this._setState({ count: this._state.count + 1 });
  }

  _render() {
    this._element.textContent = `Count: ${this._state.count}`;
  }
}

True Private Fields: #privateProp (ES2022)

ES2022 introduced private class fields using the # prefix. Unlike the underscore convention, # fields are enforced by the JavaScript engine. They are completely inaccessible from outside the class body.

Basic Syntax

class BankAccount {
  // Private fields must be DECLARED in the class body

  constructor(owner, balance) {
    this.owner = owner;       // Public property
    this.#balance = balance;  // Private field
    this.#accountId = Math.random().toString(36).slice(2, 10);
  }

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

  withdraw(amount) {
    if (amount <= 0) throw new Error("Amount must be positive");
    if (amount > this.#balance) throw new Error("Insufficient funds");
    this.#balance -= amount;
    this.#logTransaction("withdrawal", amount);
  }

  getBalance() {
    return this.#balance;
  }

  // Private method (see next section)
    this.#transactionLog.push({
      type,
      amount,
      date: new Date(),
      balance: this.#balance
    });
  }
}

const account = new BankAccount("Alice", 1000);
account.deposit(500);
console.log(account.getBalance()); // 1500

Private Fields Must Be Declared

Unlike public properties, private fields must be declared in the class body before they can be used. You cannot dynamically add private fields:

class Example {

  constructor() {
    this.#declared = 42;      // Fine (field was declared)
    // this.#notDeclared = 1; // SyntaxError: Private field '#notDeclared' must be declared
  }
}

Private Field Naming Rules

Private field names always start with #. The # is part of the name itself:

class Demo {

  showValue() {
    // "#value" is the full name of the field
    console.log(this.#value);

    // "value" (without #) is a completely different property
    this.value = 20; // This creates a PUBLIC property called "value"

    console.log(this.#value); // 10 (private field unchanged)
    console.log(this.value);  // 20 (public property)
  }
}

const d = new Demo();
d.showValue();
// 10
// 10
// 20

#value and value are entirely separate. They can coexist on the same object without any conflict.

Private Fields with Default Values

class UserSettings {

  constructor(overrides = {}) {
    if (overrides.theme) this.#theme = overrides.theme;
    if (overrides.fontSize) this.#fontSize = overrides.fontSize;
    if (overrides.notifications !== undefined) {
      this.#notifications = overrides.notifications;
    }
    if (overrides.language) this.#language = overrides.language;
  }

  getSettings() {
    return {
      theme: this.#theme,
      fontSize: this.#fontSize,
      notifications: this.#notifications,
      language: this.#language
    };
  }

  setTheme(theme) {
    const valid = ["light", "dark", "system"];
    if (!valid.includes(theme)) {
      throw new Error(`Invalid theme: ${theme}. Use: ${valid.join(", ")}`);
    }
    this.#theme = theme;
  }
}

const settings = new UserSettings({ theme: "dark" });
console.log(settings.getSettings());
// { theme: 'dark', fontSize: 16, notifications: true, language: 'en' }

settings.setTheme("system");  // Works
// settings.setTheme("neon"); // Error: Invalid theme: neon

Private Methods: #privateMethod()

Methods can also be private. Private methods are declared with the # prefix and are only callable from within the class body.

Basic Private Methods

class PasswordManager {

  addPassword(site, password) {
    if (!this.#isStrongPassword(password)) {
      throw new Error("Password is too weak");
    }
    const encrypted = this.#encrypt(password);
    this.#passwords.set(site, encrypted);
    console.log(`Password saved for ${site}`);
  }

  getPassword(site) {
    const encrypted = this.#passwords.get(site);
    if (!encrypted) return null;
    return this.#decrypt(encrypted);
  }

  // Private methods: implementation details hidden from consumers
    return (
      password.length >= 8 &&
      /[A-Z]/.test(password) &&
      /[a-z]/.test(password) &&
      /[0-9]/.test(password)
    );
  }

    // Simplified: real encryption would be much more complex
    return btoa(text);
  }

    return atob(encoded);
  }
}

const pm = new PasswordManager();
pm.addPassword("github.com", "MyStr0ngPass");
console.log(pm.getPassword("github.com")); // "MyStr0ngPass"

// Cannot access private methods
// pm.#encrypt("test");       // SyntaxError
// pm.#isStrongPassword("x"); // SyntaxError

Private Getters and Setters

You can also create private accessors:

class Temperature {

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

  // Private getter
  get #fahrenheit() {
    return this.#celsius * 9/5 + 32;
  }

  // Private setter
  set #fahrenheit(f) {
    this.#celsius = (f - 32) * 5/9;
  }

  // Public interface using private accessors internally
  display(unit = "C") {
    if (unit === "C") return `${this.#celsius.toFixed(1)}°C`;
    if (unit === "F") return `${this.#fahrenheit.toFixed(1)}°F`;
    throw new Error(`Unknown unit: ${unit}`);
  }

  adjustFahrenheit(delta) {
    this.#fahrenheit = this.#fahrenheit + delta;
  }
}

const temp = new Temperature(100);
console.log(temp.display("C")); // "100.0°C"
console.log(temp.display("F")); // "212.0°F"

temp.adjustFahrenheit(-32);
console.log(temp.display("C")); // "82.2°C" (adjusted internally via private setter)

Practical Example: Event Emitter with Private Internals

class EventEmitter {

  on(event, callback) {
    this.#ensureEvent(event);
    if (this.#listeners[event].length >= this.#maxListeners) {
      console.warn(
        `Warning: ${event} has more than ${this.#maxListeners} listeners. ` +
        `Possible memory leak.`
      );
    }
    this.#listeners[event].push(callback);
    return this;
  }

  off(event, callback) {
    this.#ensureEvent(event);
    this.#listeners[event] = this.#listeners[event].filter(cb => cb !== callback);
    return this;
  }

  emit(event, ...args) {
    this.#ensureEvent(event);
    const handlers = [...this.#listeners[event]]; // Copy to avoid mutation issues
    for (const handler of handlers) {
      this.#safeCall(handler, args);
    }
    return this;
  }

  // Private helpers
    if (!this.#listeners[event]) {
      this.#listeners[event] = [];
    }
  }

    try {
      handler(...args);
    } catch (error) {
      console.error("Error in event handler:", error);
    }
  }

  // Public method to configure the private #maxListeners
  setMaxListeners(n) {
    if (typeof n !== "number" || n < 0) {
      throw new TypeError("maxListeners must be a non-negative number");
    }
    this.#maxListeners = n;
    return this;
  }
}

Private Static Fields and Methods

Static members can also be private. Private static fields and methods are accessible only within the class body, just like private instance members.

Private Static Fields

class IdGenerator {
  // Private static field: shared across all instances, but not accessible outside
  static #nextId = 1;


  constructor() {
    this.#id = IdGenerator.#nextId++;
  }

  getId() {
    return this.#id;
  }

  static getNextId() {
    return IdGenerator.#nextId;
  }

  static resetCounter() {
    IdGenerator.#nextId = 1;
  }
}

const a = new IdGenerator();
const b = new IdGenerator();
const c = new IdGenerator();

console.log(a.getId()); // 1
console.log(b.getId()); // 2
console.log(c.getId()); // 3
console.log(IdGenerator.getNextId()); // 4 (the next available ID)

// Cannot access the private static directly
// console.log(IdGenerator.#nextId); // SyntaxError

Private Static Methods

class Validator {
  // Private static methods: internal helpers for public static methods
  static #isNonEmpty(value) {
    return value !== null && value !== undefined && value !== "";
  }

  static #matchesPattern(value, pattern) {
    return typeof value === "string" && pattern.test(value);
  }

  // Public API
  static validateEmail(email) {
    if (!this.#isNonEmpty(email)) return { valid: false, error: "Email is required" };
    if (!this.#matchesPattern(email, /^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
      return { valid: false, error: "Invalid email format" };
    }
    return { valid: true };
  }

  static validatePassword(password) {
    if (!this.#isNonEmpty(password)) {
      return { valid: false, error: "Password is required" };
    }
    if (password.length < 8) {
      return { valid: false, error: "Password must be at least 8 characters" };
    }
    if (!this.#matchesPattern(password, /[A-Z]/)) {
      return { valid: false, error: "Password must contain an uppercase letter" };
    }
    if (!this.#matchesPattern(password, /[0-9]/)) {
      return { valid: false, error: "Password must contain a number" };
    }
    return { valid: true };
  }
}

console.log(Validator.validateEmail("alice@example.com"));
// { valid: true }

console.log(Validator.validateEmail("not-an-email"));
// { valid: false, error: 'Invalid email format' }

console.log(Validator.validatePassword("short"));
// { valid: false, error: 'Password must be at least 8 characters' }

// Private static methods are inaccessible
// Validator.#isNonEmpty("test"); // SyntaxError

Singleton with Private Static

class AppConfig {
  static #instance = null;
  static #frozen = false;


  constructor(settings) {
    if (AppConfig.#instance) {
      throw new Error("Use AppConfig.getInstance() to access the configuration");
    }
    this.#settings = { ...settings };
    AppConfig.#instance = this;
  }

  static getInstance() {
    if (!AppConfig.#instance) {
      throw new Error("AppConfig has not been initialized. Call AppConfig.init() first.");
    }
    return AppConfig.#instance;
  }

  static init(settings) {
    if (AppConfig.#instance) {
      throw new Error("AppConfig has already been initialized");
    }
    return new AppConfig(settings);
  }

  get(key) {
    return this.#settings[key];
  }

  set(key, value) {
    if (AppConfig.#frozen) {
      throw new Error("Configuration is frozen and cannot be modified");
    }
    this.#settings[key] = value;
  }

  static freeze() {
    AppConfig.#frozen = true;
  }

  getAll() {
    return { ...this.#settings };
  }
}

AppConfig.init({
  apiUrl: "https://api.example.com",
  debug: false,
  maxRetries: 3
});

const config = AppConfig.getInstance();
console.log(config.get("apiUrl"));  // "https://api.example.com"

config.set("debug", true);
console.log(config.get("debug"));   // true

AppConfig.freeze();
// config.set("debug", false);      // Error: Configuration is frozen

Accessing Private Fields from Outside (You Can't!)

Private fields with # provide hard privacy. There is no workaround, no reflection API, and no way to access them from outside the class body in standard JavaScript.

Every Attempt Fails

class Secret {

  getValue() {
    return this.#value;
  }
}

const obj = new Secret();

// Attempt 1: Direct access
// console.log(obj.#value); // SyntaxError: Private field '#value' must be declared in an enclosing class

// Attempt 2: Bracket notation
// console.log(obj["#value"]); // undefined ()"#value" is a string property name, not the private field)

// Attempt 3: Object.keys / Object.getOwnPropertyNames
console.log(Object.keys(obj));                    // []
console.log(Object.getOwnPropertyNames(obj));     // []
console.log(Object.getOwnPropertyDescriptors(obj)); // {}

// Attempt 4: for...in loop
for (const key in obj) {
  console.log(key); // Nothing printed
}

// Attempt 5: JSON.stringify
console.log(JSON.stringify(obj));   // "{}" (private fields are not included)

// Attempt 6: Reflect
console.log(Reflect.ownKeys(obj));  // [] (private fields are not "keys")

// Attempt 7: Proxy
const proxy = new Proxy(obj, {
  get(target, prop) {
    console.log(`Accessing: ${String(prop)}`);
    return Reflect.get(target, prop);
  }
});
proxy.getValue(); // Works, but the Proxy never sees "#value" being accessed internally

// The ONLY way to access the value is through the public interface
console.log(obj.getValue()); // 42

Private fields are truly invisible to all external code. They do not show up in property enumeration, reflection, serialization, or any other mechanism.

# Is Not a String Property

A common misconception is that #value is just a property named "#value". It is not. Private fields exist in a completely separate namespace:

class Demo {

  constructor() {
    this["#x"] = "public string property named #x";
  }

  show() {
    console.log(this.#x);     // "private"                          (the private field)
    console.log(this["#x"]);  // "public string property named #x"  (a regular property)
  }
}

const d = new Demo();
d.show();

console.log(d["#x"]);         // "public string property named #x"  (accessible)
// console.log(d.#x);         // SyntaxError                        (private, inaccessible)

Checking for Private Fields: in Operator

You can use the in operator to check if an object has a specific private field. This only works inside the class body:

class Color {

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

  static isColor(obj) {
    // Check if obj has the private field #r
    // This works because we're inside the Color class body
    return #r in obj;
  }

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

const red = new Color(255, 0, 0);
const notAColor = { r: 255, g: 0, b: 0 };

console.log(Color.isColor(red));        // true
console.log(Color.isColor(notAColor));  // false
console.log(Color.isColor(42));         // false
console.log(Color.isColor(null));       // false

This #field in obj check is called the ergonomic brand check. It lets you safely determine if an object is a genuine instance of your class, even without using instanceof.

Private Fields and Inheritance

Private fields have a strict relationship with inheritance: subclasses cannot access the private fields of their parent class. This is different from "protected" in languages like Java, C#, or Python, where subclasses can access protected members.

Subclasses Cannot Access Parent's Private Fields

class Animal {

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

  getName() {
    return this.#name;
  }

  getEnergy() {
    return this.#energy;
  }

  eat(amount) {
    this.#energy += amount;
    console.log(`${this.#name} eats. Energy: ${this.#energy}`);
  }
}

class Dog extends Animal {

  constructor(name, energy, breed) {
    super(name, energy);
    this.#breed = breed;
  }

  bark() {
    // CANNOT access parent's private fields
    // console.log(this.#name);   // SyntaxError: Private field '#name' must be declared in an enclosing class
    // console.log(this.#energy); // SyntaxError

    // Must use public methods to access parent's data
    console.log(`${this.getName()} (${this.#breed}) barks! Energy: ${this.getEnergy()}`);
  }
}

const rex = new Dog("Rex", 100, "German Shepherd");
rex.bark();   // "Rex (German Shepherd) barks! Energy: 100"
rex.eat(20);  // "Rex eats. Energy: 120"

Each Class Has Its Own Private Namespace

A parent and a child can each have private fields with the same name. They are completely independent:

class Parent {

  getParentValue() {
    return this.#value;
  }
}

class Child extends Parent {

  getChildValue() {
    return this.#value;
  }
}

const child = new Child();
console.log(child.getParentValue());  // "parent's private value"
console.log(child.getChildValue());   // "child's private value"

The #value in Parent and the #value in Child are two separate private fields stored on the same object. They do not interfere with each other.

The "Protected" Gap

JavaScript's private fields are more restrictive than "protected" in many other languages. There is no built-in "protected" visibility that allows subclass access but prevents external access.

This is an intentional design choice. If you need subclass access, you have two options:

Option 1: Use the underscore convention for "protected" members

class Animal {
  _name;    // "Protected" by convention  (subclasses can access)

  constructor(name) {
    this._name = name;
    this.#id = Math.random().toString(36).slice(2);
  }

  getPublicId() {
    return this.#id;
  }
}

class Dog extends Animal {
  speak() {
    // Can access _name (convention-based "protected")
    console.log(`${this._name} barks!`);

    // Cannot access #id (truly private)
    // console.log(this.#id); // SyntaxError
  }
}

Option 2: Expose through public or protected methods

class Animal {

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

  // Public getters that subclasses can use
  get name() { return this.#name; }
  get energy() { return this.#energy; }

  // Protected-like method: public, but intended for subclass use
  _modifyEnergy(amount) {
    this.#energy += amount;
  }
}

class Dog extends Animal {
  fetch(distance) {
    // Use the public getter
    console.log(`${this.name} runs ${distance}m to fetch the ball`);

    // Use the "protected" method
    this._modifyEnergy(-distance);
    console.log(`Energy remaining: ${this.energy}`);
  }
}

const rex = new Dog("Rex", 100);
rex.fetch(30);
// "Rex runs 30m to fetch the ball"
// "Energy remaining: 70"

Protected in Practice: Read-Only via Getters

A very common encapsulation pattern is making properties readable from outside but writable only from inside the class. This is achieved by combining private fields with public getters (and optionally, no public setters).

Read-Only Public Access

class User {

  constructor(name, email) {
    this.#name = name;
    this.#email = email;
    this.#createdAt = new Date();
    this.#loginCount = 0;
  }

  // Public getters: read-only from outside
  get name() { return this.#name; }
  get email() { return this.#email; }
  get createdAt() { return this.#createdAt; }
  get loginCount() { return this.#loginCount; }

  // Controlled write access through methods
  login() {
    this.#loginCount++;
    console.log(`${this.#name} logged in (${this.#loginCount} times)`);
  }

  updateEmail(newEmail) {
    if (!newEmail.includes("@")) {
      throw new Error("Invalid email format");
    }
    const oldEmail = this.#email;
    this.#email = newEmail;
    console.log(`Email changed: ${oldEmail} → ${newEmail}`);
  }
}

const alice = new User("Alice", "alice@example.com");

// Read access works through getters
console.log(alice.name);        // "Alice"
console.log(alice.email);       // "alice@example.com"
console.log(alice.loginCount);  // 0

// Write access is controlled
alice.login();
alice.login();
console.log(alice.loginCount);  // 2

alice.updateEmail("alice@newdomain.com");
console.log(alice.email);       // "alice@newdomain.com"

// Direct modification is impossible
alice.name = "Hacker";
console.log(alice.name);        // "Alice" (unchanged! setter doesn't exist, assignment is ignored)

Getters with Validation Setters

Sometimes you want to allow external writes but with validation:

class Temperature {

  constructor(celsius) {
    this.celsius = celsius; // Use the setter for validation
  }

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

  set celsius(value) {
    if (typeof value !== "number" || isNaN(value)) {
      throw new TypeError("Temperature must be a number");
    }
    if (value < -273.15) {
      throw new RangeError("Temperature cannot be below absolute zero");
    }
    this.#celsius = value;
  }

  get fahrenheit() {
    return this.#celsius * 9/5 + 32;
  }

  set fahrenheit(value) {
    // Convert and use the celsius setter (which validates)
    this.celsius = (value - 32) * 5/9;
  }

  get kelvin() {
    return this.#celsius + 273.15;
  }

  set kelvin(value) {
    this.celsius = value - 273.15;
  }

  toString() {
    return `${this.#celsius.toFixed(1)}°C / ${this.fahrenheit.toFixed(1)}°F / ${this.kelvin.toFixed(1)}K`;
  }
}

const temp = new Temperature(100);
console.log(`${temp}`);   // "100.0°C / 212.0°F / 373.1K"

temp.fahrenheit = 32;
console.log(`${temp}`);   // "0.0°C / 32.0°F / 273.1K"

temp.kelvin = 0;
console.log(`${temp}`);   // "-273.1°C / -459.7°F / 0.0K"

// Validation prevents invalid values
// temp.celsius = -300;   // RangeError: Temperature cannot be below absolute zero
// temp.celsius = "hot";  // TypeError: Temperature must be a number

Computed Properties with Caching

Getters can compute values on demand while private fields hold the raw data:

class Circle {

  constructor(radius) {
    this.radius = radius; // Use setter
  }

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

  set radius(value) {
    if (value < 0) throw new RangeError("Radius cannot be negative");
    this.#radius = value;
    this.#cachedArea = null; // Invalidate cache when radius changes
  }

  get area() {
    if (this.#cachedArea === null) {
      console.log("Computing area...");
      this.#cachedArea = Math.PI * this.#radius ** 2;
    }
    return this.#cachedArea;
  }

  get circumference() {
    return 2 * Math.PI * this.#radius;
  }

  get diameter() {
    return this.#radius * 2;
  }

  set diameter(d) {
    this.radius = d / 2; // Delegates to the radius setter (with validation)
  }
}

const circle = new Circle(5);
console.log(circle.area); // Computing area... → 78.53981633974483
console.log(circle.area); // 78.53981633974483 (cached, no recomputation)

circle.radius = 10;       // Invalidates cache
console.log(circle.area); // Computing area... → 314.1592653589793

A Complete Real-World Example

class ShoppingCart {

  // Read-only access to items (returns a copy to prevent external mutation)
  get items() {
    return this.#items.map(item => ({ ...item }));
  }

  get itemCount() {
    return this.#items.reduce((sum, item) => sum + item.quantity, 0);
  }

  get subtotal() {
    return this.#items.reduce(
      (sum, item) => sum + item.price * item.quantity, 0
    );
  }

  get discount() {
    return this.subtotal * (this.#discountPercent / 100);
  }

  get total() {
    return this.subtotal - this.discount;
  }

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

  // Controlled mutations through methods
  addItem(name, price, quantity = 1) {
    const existing = this.#items.find(item => item.name === name);
    if (existing) {
      existing.quantity += quantity;
    } else {
      this.#items.push({ name, price, quantity });
    }
  }

  removeItem(name) {
    const index = this.#items.findIndex(item => item.name === name);
    if (index === -1) throw new Error(`Item "${name}" not found in cart`);
    this.#items.splice(index, 1);
  }

  applyDiscount(code) {
    const discounts = { SAVE10: 10, SAVE20: 20, HALF: 50 };
    if (!(code in discounts)) {
      throw new Error(`Invalid discount code: ${code}`);
    }
    this.#discountCode = code;
    this.#discountPercent = discounts[code];
  }

  clear() {
    this.#items = [];
    this.#discountCode = null;
    this.#discountPercent = 0;
  }

  toString() {
    const lines = this.#items.map(
      item => `  ${item.name} x${item.quantity} @ $${item.price.toFixed(2)}`
    );
    let summary = lines.join("\n");
    summary += `\n  Subtotal: $${this.subtotal.toFixed(2)}`;
    if (this.#discountCode) {
      summary += `\n  Discount (${this.#discountCode}): -$${this.discount.toFixed(2)}`;
    }
    summary += `\n  Total: $${this.total.toFixed(2)}`;
    return summary;
  }
}

const cart = new ShoppingCart();
cart.addItem("Laptop", 999.99);
cart.addItem("Mouse", 29.99, 2);
cart.addItem("Keyboard", 79.99);
cart.applyDiscount("SAVE10");

console.log(`${cart}`);
//   Laptop x1 @ $999.99
//   Mouse x2 @ $29.99
//   Keyboard x1 @ $79.99
//   Subtotal: $1139.96
//   Discount (SAVE10): -$114.00
//   Total: $1025.96

// Cannot tamper with internals
const items = cart.items;
items.push({ name: "Free Stuff", price: 0, quantity: 100 });
console.log(cart.itemCount); // 4 (unaffected, we got a copy)

Convention vs. Private Fields: When to Use Which

Aspect _underscore Convention #private Fields
Enforcement None (developer discipline) Engine-enforced
Subclass access Yes No
External access Possible (but discouraged) Impossible
Enumerable Yes (shows in Object.keys) No
JSON serialization Included Excluded
Proxy/Reflect Visible Invisible
Performance Same as regular properties Slightly different internal handling
Use case "Protected" members for subclasses Truly hidden implementation details

Summary

Concept Key Takeaway
_underscore convention A naming convention signaling internal use; no enforcement; subclasses can access
#private fields True privacy enforced by the engine; declared in class body; completely hidden from outside
Private methods #method() Methods only callable from within the class body
Private static fields/methods Class-level private members; shared across class, hidden from outside
No external access # fields are invisible to Object.keys, for...in, JSON.stringify, Proxy, and bracket notation
No subclass access # fields in a parent are inaccessible in child classes; use public methods or _ convention for "protected"
#field in obj Brand check: tests if an object has a specific private field (only inside the class body)
Read-only via getters Combine private fields with public getters for read-only external access
Validated setters Public setters with validation logic protect private state from invalid values

Encapsulation through private fields is one of the most important tools for writing maintainable JavaScript. It lets you change internal implementation details without breaking external code, enforce invariants through controlled access, and create clear, reliable public interfaces. Use # private fields for hard boundaries and _ underscore convention for softer, subclass-friendly boundaries.

Navigation