Skip to main content

Private and Protected Properties and Methods in JavaScript

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
#balance;
#transactionLog = [];
#accountId;

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)
#logTransaction(type, amount) {
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 {
#declared = 0; // Must be declared here

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 {
#value = 10;

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 {
#theme = "light";
#fontSize = 16;
#notifications = true;
#language = "en";

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 {
#passwords = new Map();

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
#isStrongPassword(password) {
return (
password.length >= 8 &&
/[A-Z]/.test(password) &&
/[a-z]/.test(password) &&
/[0-9]/.test(password)
);
}

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

#decrypt(encoded) {
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 {
#celsius;

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 {
#listeners = {};
#maxListeners = 10;

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
#ensureEvent(event) {
if (!this.#listeners[event]) {
this.#listeners[event] = [];
}
}

#safeCall(handler, args) {
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;

#id;

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;

#settings;

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 {
#value = 42;

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 {
#x = "private";

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 {
#r; #g; #b;

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 {
#name;
#energy;

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 {
#breed;

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 {
#value = "parent's private value";

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

class Child extends Parent {
#value = "child's private value"; // Different field, same name!

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)
#id; // Truly private (only Animal 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 {
#name;
#energy;

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 {
#name;
#email;
#createdAt;
#loginCount;

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 {
#celsius;

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 {
#radius;
#cachedArea = null;

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 {
#items = [];
#discountCode = null;
#discountPercent = 0;

// 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
EnforcementNone (developer discipline)Engine-enforced
Subclass accessYesNo
External accessPossible (but discouraged)Impossible
EnumerableYes (shows in Object.keys)No
JSON serializationIncludedExcluded
Proxy/ReflectVisibleInvisible
PerformanceSame as regular propertiesSlightly different internal handling
Use case"Protected" members for subclassesTruly hidden implementation details
tip

Use # private fields for implementation details that should never be exposed: internal state, caches, computed intermediary values, and anything that external code should not depend on. Use _ underscore convention when you want subclasses to access internal members but want to signal to external consumers that these members are not part of the public API.

Summary

ConceptKey Takeaway
_underscore conventionA naming convention signaling internal use; no enforcement; subclasses can access
#private fieldsTrue 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/methodsClass-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 objBrand check: tests if an object has a specific private field (only inside the class body)
Read-only via gettersCombine private fields with public getters for read-only external access
Validated settersPublic 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.