JavaScript Private Class Fields and Methods

For years, JavaScript developers relied on a gentleman's agreement: prefix private properties with an underscore and pretend they don't exist outside the class. This convention worked until it...

Key Insights

  • JavaScript’s # prefix syntax provides true privacy for class fields and methods, unlike the old underscore convention which was purely cosmetic and offered no actual encapsulation.
  • Private fields must be declared before use and cannot be dynamically added, creating a clear contract about a class’s internal structure at declaration time.
  • Private members are not inherited by subclasses and cannot be accessed via reflection, making them fundamentally different from other JavaScript properties and requiring careful consideration in class hierarchies.

Introduction to Privacy in JavaScript Classes

For years, JavaScript developers relied on a gentleman’s agreement: prefix private properties with an underscore and pretend they don’t exist outside the class. This convention worked until it didn’t—nothing stopped external code from accessing _privateField, and refactoring tools couldn’t distinguish between internal implementation details and actual private APIs.

True private class fields and methods, introduced in ES2022, fundamentally change this. The # prefix creates genuinely inaccessible members that throw errors when accessed from outside their class. This isn’t just better convention—it’s enforced encapsulation.

// Old convention-based approach
class OldWay {
  constructor() {
    this._private = 'not really private';
  }
}

const old = new OldWay();
console.log(old._private); // Works fine, defeating the "privacy"

// New private fields
class NewWay {
  #private = 'actually private';
  
  getPrivate() {
    return this.#private;
  }
}

const modern = new NewWay();
console.log(modern.#private); // SyntaxError: Private field '#private' must be declared
console.log(modern.getPrivate()); // Works: 'actually private'

This matters because encapsulation isn’t about being secretive—it’s about creating clear contracts. When implementation details are truly private, you can refactor them without breaking external code. Your public API becomes intentional rather than accidental.

Private Fields Syntax and Usage

Private fields use the # prefix and must be declared in the class body before use. Unlike public properties, you cannot dynamically add private fields—they’re part of the class’s structure from the moment it’s defined.

class User {
  // Must declare private fields here
  #password;
  #loginAttempts = 0;
  #maxAttempts = 3;
  
  constructor(username, password) {
    this.username = username; // Public field
    this.#password = this.#hash(password); // Private field
  }
  
  login(password) {
    if (this.#loginAttempts >= this.#maxAttempts) {
      throw new Error('Account locked');
    }
    
    if (this.#hash(password) === this.#password) {
      this.#loginAttempts = 0;
      return true;
    }
    
    this.#loginAttempts++;
    return false;
  }
  
  #hash(value) {
    // Simplified for example
    return `hashed_${value}`;
  }
}

const user = new User('alice', 'secret123');
console.log(user.username); // 'alice'
console.log(user.#password); // SyntaxError
console.log(user.#loginAttempts); // SyntaxError

The declaration requirement might seem restrictive, but it’s intentional. It forces you to think about your class’s internal state upfront and prevents the sprawling, undocumented internal properties that plague convention-based approaches.

Private Methods and Accessors

Private methods follow the same # syntax and come in several flavors: instance methods, static methods, getters, and setters. Each serves a distinct purpose in your encapsulation strategy.

class DataProcessor {
  #rawData;
  #processedCache = null;
  
  constructor(data) {
    this.#rawData = data;
  }
  
  // Private instance method
  #validate() {
    if (!Array.isArray(this.#rawData)) {
      throw new Error('Data must be an array');
    }
    return this.#rawData.every(item => typeof item === 'number');
  }
  
  // Private getter
  get #processed() {
    if (!this.#processedCache) {
      this.#processedCache = this.#transform();
    }
    return this.#processedCache;
  }
  
  // Private setter
  set #processed(value) {
    this.#processedCache = value;
  }
  
  // Private static method
  static #normalize(value) {
    return value / 100;
  }
  
  #transform() {
    return this.#rawData.map(DataProcessor.#normalize);
  }
  
  // Public API
  getResults() {
    if (!this.#validate()) {
      throw new Error('Invalid data format');
    }
    return this.#processed;
  }
  
  invalidateCache() {
    this.#processed = null;
  }
}

Private getters and setters are particularly useful for lazy initialization and internal state management. Private static methods work well for utility functions that support the class but don’t need to be exposed.

Practical Use Cases and Patterns

Private members shine when modeling real-world entities with complex internal state and business rules. Here’s a complete BankAccount implementation that demonstrates proper encapsulation:

class BankAccount {
  #balance = 0;
  #transactions = [];
  #accountNumber;
  #isLocked = false;
  
  static #nextAccountNumber = 1000;
  
  constructor(initialDeposit = 0) {
    this.#accountNumber = BankAccount.#generateAccountNumber();
    if (initialDeposit > 0) {
      this.#addTransaction('deposit', initialDeposit);
      this.#balance = initialDeposit;
    }
  }
  
  static #generateAccountNumber() {
    return `ACC${this.#nextAccountNumber++}`;
  }
  
  #addTransaction(type, amount) {
    this.#transactions.push({
      type,
      amount,
      timestamp: Date.now(),
      balance: this.#balance
    });
  }
  
  #validateAmount(amount) {
    if (typeof amount !== 'number' || amount <= 0) {
      throw new Error('Amount must be a positive number');
    }
  }
  
  #checkLocked() {
    if (this.#isLocked) {
      throw new Error('Account is locked');
    }
  }
  
  deposit(amount) {
    this.#checkLocked();
    this.#validateAmount(amount);
    this.#balance += amount;
    this.#addTransaction('deposit', amount);
    return this.#balance;
  }
  
  withdraw(amount) {
    this.#checkLocked();
    this.#validateAmount(amount);
    
    if (amount > this.#balance) {
      throw new Error('Insufficient funds');
    }
    
    this.#balance -= amount;
    this.#addTransaction('withdrawal', amount);
    return this.#balance;
  }
  
  getBalance() {
    return this.#balance;
  }
  
  getAccountNumber() {
    return this.#accountNumber;
  }
  
  getTransactionHistory() {
    // Return a copy to prevent external modification
    return [...this.#transactions];
  }
  
  lock() {
    this.#isLocked = true;
  }
  
  unlock() {
    this.#isLocked = false;
  }
}

const account = new BankAccount(1000);
account.deposit(500);
account.withdraw(200);
console.log(account.getBalance()); // 1300
console.log(account.getTransactionHistory()); // Full history
// account.#balance = 999999; // SyntaxError - can't cheat!

This design ensures that balance changes only happen through validated transactions, transaction history remains immutable from outside, and the account number can’t be modified after creation.

Private vs. WeakMap and Closures

Before private fields, developers used WeakMaps or closures to achieve true privacy. Understanding these alternatives helps you appreciate what private fields offer and when you might still need the older patterns.

// Using private fields (modern)
class CounterPrivate {
  #count = 0;
  
  increment() { return ++this.#count; }
  getCount() { return this.#count; }
}

// Using WeakMap (pre-private fields)
const counterData = new WeakMap();

class CounterWeakMap {
  constructor() {
    counterData.set(this, { count: 0 });
  }
  
  increment() {
    const data = counterData.get(this);
    return ++data.count;
  }
  
  getCount() {
    return counterData.get(this).count;
  }
}

// Using closures (functional approach)
function createCounter() {
  let count = 0;
  
  return {
    increment() { return ++count; },
    getCount() { return count; }
  };
}

// Performance and usage
const p = new CounterPrivate();
const w = new CounterWeakMap();
const c = createCounter();

console.log(p.increment()); // 1
console.log(w.increment()); // 1
console.log(c.increment()); // 1

Private fields are faster than WeakMaps (no hash lookup) and more explicit than closures. They also work naturally with instanceof and class hierarchies. However, WeakMaps still have a place when you need to attach private data to objects you don’t control, and closures remain useful for creating lightweight instances without the class machinery.

Limitations and Gotchas

Private fields have sharp edges that catch developers off guard. The most significant: they’re not inherited.

class Parent {
  #secret = 'parent secret';
  
  revealSecret() {
    return this.#secret;
  }
}

class Child extends Parent {
  #secret = 'child secret'; // Different field entirely!
  
  revealBoth() {
    return {
      parent: super.revealSecret(), // Accesses Parent's #secret
      child: this.#secret           // Accesses Child's #secret
    };
  }
}

const child = new Child();
console.log(child.revealBoth()); 
// { parent: 'parent secret', child: 'child secret' }

Each class gets its own private namespace. This isn’t a bug—it’s intentional to prevent subclasses from accidentally breaking parent class invariants. But it means you can’t create “protected” members that subclasses can access.

Testing private methods is another challenge. You can’t directly test #validateAmount() from outside the class. Solutions include:

  1. Test through the public API (preferred)
  2. Extract logic to a separate module and test that
  3. Use conditional exports in test environments (hacky but sometimes necessary)

JSON serialization ignores private fields entirely:

class Person {
  #ssn;
  name;
  
  constructor(name, ssn) {
    this.name = name;
    this.#ssn = ssn;
  }
}

const person = new Person('Alice', '123-45-6789');
console.log(JSON.stringify(person)); // {"name":"Alice"}
// #ssn is not included

This is actually a feature for sensitive data, but it means you need custom serialization logic if you want to persist private state.

Best Practices

Use private fields for implementation details that external code should never touch. Make fields private by default and only expose them if there’s a clear reason.

class EmailService {
  // Private: implementation details
  #apiKey;
  #rateLimiter;
  #retryCount = 3;
  
  // Public: stable interface
  from;
  
  constructor(apiKey, from) {
    this.#apiKey = apiKey;
    this.from = from;
    this.#rateLimiter = this.#createRateLimiter();
  }
  
  #createRateLimiter() {
    // Complex setup that consumers don't need to know about
    return { /* ... */ };
  }
  
  #shouldRetry(error, attempt) {
    return attempt < this.#retryCount && error.retryable;
  }
  
  async #sendWithRetry(email, attempt = 0) {
    try {
      return await this.#actualSend(email);
    } catch (error) {
      if (this.#shouldRetry(error, attempt)) {
        return this.#sendWithRetry(email, attempt + 1);
      }
      throw error;
    }
  }
  
  #actualSend(email) {
    // API call implementation
  }
  
  // Public API: simple and focused
  async send(to, subject, body) {
    const email = { from: this.from, to, subject, body };
    return this.#sendWithRetry(email);
  }
}

This class exposes a simple send() method while hiding retry logic, rate limiting, and API key management. Users get a clean API; you retain flexibility to change the implementation.

Balance encapsulation with pragmatism. Not everything needs to be private. If you find yourself creating getters and setters for every private field, you’re probably over-engineering. Private fields work best for data that genuinely needs protection: passwords, internal counters, cache state, validation rules.

Private class fields represent a maturation of JavaScript’s object-oriented capabilities. They’re not just syntactic sugar—they’re a fundamental shift toward intentional, enforceable encapsulation. Use them to build robust, maintainable classes with clear public contracts and hidden complexity.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.