JavaScript Modern

JavaScript Private Fields: True Class Encapsulation

Master JavaScript private fields and methods for true encapsulation in classes. Learn the # syntax, private methods, static private members, and best practices.

By JavaScript Document Team
private-fieldsclasseses2022encapsulationoop

Private fields and methods in JavaScript provide true encapsulation for class members using the # prefix. Introduced in ES2022, they offer genuine privacy that cannot be accessed from outside the class, unlike the convention-based underscore prefix.

Understanding Private Fields

Private fields are class properties that are truly private to the class, inaccessible from outside the class definition.

Basic Private Field Syntax

class BankAccount {
  // Private fields must be declared upfront
  #balance;
  #accountNumber;

  constructor(initialBalance, accountNumber) {
    this.#balance = initialBalance;
    this.#accountNumber = accountNumber;
  }

  deposit(amount) {
    if (amount > 0) {
      this.#balance += amount;
      return true;
    }
    return false;
  }

  withdraw(amount) {
    if (amount > 0 && amount <= this.#balance) {
      this.#balance -= amount;
      return true;
    }
    return false;
  }

  getBalance() {
    return this.#balance;
  }

  // Private fields are not accessible via this
  getAccountInfo() {
    return {
      accountNumber: this.#accountNumber,
      balance: this.#balance,
    };
  }
}

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

// Cannot access private fields from outside
console.log(account.#balance); // SyntaxError: Private field '#balance' must be declared in an enclosing class
console.log(account['#balance']); // undefined (not accessible)

// Private fields are not enumerable
console.log(Object.keys(account)); // []
console.log(Object.getOwnPropertyNames(account)); // []

// Cannot access via prototype
console.log(BankAccount.prototype.#balance); // SyntaxError

Private Fields vs Public Fields

class User {
  // Public field
  name;
  email;

  // Private fields
  #id;
  #password;
  #loginAttempts = 0;
  #locked = false;

  constructor(name, email, password) {
    this.name = name;
    this.email = email;
    this.#id = this.#generateId();
    this.#password = this.#hashPassword(password);
  }

  #generateId() {
    return Math.random().toString(36).substr(2, 9);
  }

  #hashPassword(password) {
    // Simple hash for demonstration
    return btoa(password);
  }

  login(password) {
    if (this.#locked) {
      throw new Error('Account is locked');
    }

    if (this.#hashPassword(password) === this.#password) {
      this.#loginAttempts = 0;
      return { success: true, userId: this.#id };
    }

    this.#loginAttempts++;
    if (this.#loginAttempts >= 3) {
      this.#locked = true;
      throw new Error('Account locked due to too many failed attempts');
    }

    return { success: false, attemptsRemaining: 3 - this.#loginAttempts };
  }

  // Public method accessing private fields
  resetPassword(oldPassword, newPassword) {
    if (this.#hashPassword(oldPassword) !== this.#password) {
      throw new Error('Invalid current password');
    }

    this.#password = this.#hashPassword(newPassword);
    this.#locked = false;
    this.#loginAttempts = 0;
  }

  // Getter for safe access
  get id() {
    return this.#id;
  }

  // No setter for id - it's read-only
}

const user = new User('John Doe', 'john@example.com', 'secret123');
console.log(user.name); // 'John Doe' (public)
console.log(user.id); // Random ID (getter)
console.log(user.#id); // SyntaxError

Private Methods

Private methods use the same # syntax and provide encapsulation for internal class logic.

Instance Private Methods

class DataProcessor {
  #data = [];
  #processed = false;

  constructor(data) {
    this.#data = [...data];
  }

  // Private method
  #validate(item) {
    if (typeof item !== 'object' || !item.id || !item.value) {
      throw new Error('Invalid data format');
    }
    return true;
  }

  // Private method
  #transform(item) {
    return {
      ...item,
      processedAt: new Date(),
      transformed: true,
      normalizedValue: this.#normalize(item.value),
    };
  }

  // Private method
  #normalize(value) {
    if (typeof value === 'string') {
      return value.trim().toLowerCase();
    }
    if (typeof value === 'number') {
      return Math.round(value * 100) / 100;
    }
    return value;
  }

  // Public method using private methods
  process() {
    if (this.#processed) {
      throw new Error('Data already processed');
    }

    const results = this.#data.map((item) => {
      this.#validate(item);
      return this.#transform(item);
    });

    this.#processed = true;
    return results;
  }

  // Private methods can call other private methods
  #analyzeData() {
    const stats = {
      total: this.#data.length,
      types: this.#getDataTypes(),
      summary: this.#generateSummary(),
    };
    return stats;
  }

  #getDataTypes() {
    const types = {};
    this.#data.forEach((item) => {
      const type = typeof item.value;
      types[type] = (types[type] || 0) + 1;
    });
    return types;
  }

  #generateSummary() {
    return `Dataset contains ${this.#data.length} items`;
  }

  // Public method exposing private analysis
  getAnalysis() {
    return this.#analyzeData();
  }
}

const processor = new DataProcessor([
  { id: 1, value: 'Hello' },
  { id: 2, value: 42.789 },
  { id: 3, value: '  World  ' },
]);

console.log(processor.process());
console.log(processor.getAnalysis());

// Cannot call private methods
// processor.#validate({ id: 1 }); // SyntaxError

Private Getters and Setters

class Temperature {
  #celsius = 0;

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

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

  // Private setter
  set #kelvin(value) {
    this.#celsius = value - 273.15;
  }

  // Public interface
  get celsius() {
    return this.#celsius;
  }

  set celsius(value) {
    if (value < -273.15) {
      throw new Error('Temperature below absolute zero');
    }
    this.#celsius = value;
  }

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

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

  get kelvin() {
    return this.#kelvin; // Use private getter
  }

  set kelvin(value) {
    if (value < 0) {
      throw new Error('Kelvin cannot be negative');
    }
    this.#kelvin = value; // Use private setter
  }

  // Private method using private getter
  #getScientificNotation() {
    return {
      celsius: this.#celsius.toExponential(),
      kelvin: this.#kelvin.toExponential(),
    };
  }

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

const temp = new Temperature(25);
console.log(temp.celsius); // 25
console.log(temp.fahrenheit); // 77
console.log(temp.kelvin); // 298.15

temp.fahrenheit = 100;
console.log(temp.celsius); // 37.77...

Static Private Members

Static private fields and methods belong to the class itself, not instances.

Static Private Fields

class IDGenerator {
  // Static private field
  static #counter = 0;
  static #prefix = 'ID';
  static #usedIds = new Set();

  // Static private method
  static #incrementCounter() {
    this.#counter++;
  }

  // Static public method accessing private static field
  static generateId() {
    this.#incrementCounter();
    const id = `${this.#prefix}-${this.#counter}`;
    this.#usedIds.add(id);
    return id;
  }

  static setPrefix(prefix) {
    this.#prefix = prefix;
  }

  static reset() {
    this.#counter = 0;
    this.#usedIds.clear();
  }

  static getStats() {
    return {
      totalGenerated: this.#counter,
      prefix: this.#prefix,
      uniqueIds: this.#usedIds.size,
    };
  }

  static isIdUsed(id) {
    return this.#usedIds.has(id);
  }
}

console.log(IDGenerator.generateId()); // ID-1
console.log(IDGenerator.generateId()); // ID-2
IDGenerator.setPrefix('USER');
console.log(IDGenerator.generateId()); // USER-3

console.log(IDGenerator.getStats());
// { totalGenerated: 3, prefix: 'USER', uniqueIds: 3 }

// Cannot access static private fields
// console.log(IDGenerator.#counter); // SyntaxError

Static Private Methods

class Validator {
  static #rules = new Map();
  static #cache = new WeakMap();

  // Static private methods
  static #compileRule(rule) {
    if (typeof rule === 'string') {
      return new RegExp(rule);
    }
    if (rule instanceof RegExp) {
      return rule;
    }
    if (typeof rule === 'function') {
      return rule;
    }
    throw new Error('Invalid rule type');
  }

  static #getCacheKey(value, ruleName) {
    return `${ruleName}:${JSON.stringify(value)}`;
  }

  static #validateWithRule(value, rule) {
    if (rule instanceof RegExp) {
      return rule.test(String(value));
    }
    if (typeof rule === 'function') {
      return rule(value);
    }
    return false;
  }

  // Public static methods
  static addRule(name, rule) {
    const compiledRule = this.#compileRule(rule);
    this.#rules.set(name, compiledRule);
  }

  static validate(value, ruleName) {
    if (!this.#rules.has(ruleName)) {
      throw new Error(`Rule '${ruleName}' not found`);
    }

    // Check cache
    if (this.#cache.has(value)) {
      const cached = this.#cache.get(value);
      if (cached.has(ruleName)) {
        return cached.get(ruleName);
      }
    }

    const rule = this.#rules.get(ruleName);
    const result = this.#validateWithRule(value, rule);

    // Cache result
    if (!this.#cache.has(value)) {
      this.#cache.set(value, new Map());
    }
    this.#cache.get(value).set(ruleName, result);

    return result;
  }

  static getRuleNames() {
    return Array.from(this.#rules.keys());
  }
}

// Add validation rules
Validator.addRule('email', /^[^\s@]+@[^\s@]+\.[^\s@]+$/);
Validator.addRule('phone', /^\d{3}-\d{3}-\d{4}$/);
Validator.addRule(
  'positive',
  (value) => typeof value === 'number' && value > 0
);

console.log(Validator.validate('test@example.com', 'email')); // true
console.log(Validator.validate('123-456-7890', 'phone')); // true
console.log(Validator.validate(42, 'positive')); // true
console.log(Validator.getRuleNames()); // ['email', 'phone', 'positive']

Advanced Private Field Patterns

Private Field Initialization

class ConfigurableClass {
  // Private fields with initialization
  #config = this.#loadDefaultConfig();
  #state = 'uninitialized';
  #handlers = new Map();

  // Private method called during field initialization
  #loadDefaultConfig() {
    return {
      debug: false,
      timeout: 5000,
      retries: 3,
    };
  }

  constructor(options = {}) {
    // Override defaults with options
    this.#config = { ...this.#config, ...options };
    this.#state = 'initialized';
    this.#setupHandlers();
  }

  #setupHandlers() {
    this.#handlers.set('error', this.#handleError.bind(this));
    this.#handlers.set('success', this.#handleSuccess.bind(this));
  }

  #handleError(error) {
    if (this.#config.debug) {
      console.error('Error occurred:', error);
    }
  }

  #handleSuccess(result) {
    if (this.#config.debug) {
      console.log('Success:', result);
    }
  }

  updateConfig(newConfig) {
    this.#config = { ...this.#config, ...newConfig };
  }

  getConfig() {
    // Return a copy to prevent external modification
    return { ...this.#config };
  }
}

Inheritance with Private Fields

class Animal {
  #species;
  #age;

  constructor(species, age) {
    this.#species = species;
    this.#age = age;
  }

  #getBasicInfo() {
    return `${this.#species}, ${this.#age} years old`;
  }

  getInfo() {
    return this.#getBasicInfo();
  }

  birthday() {
    this.#age++;
  }

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

class Dog extends Animal {
  #name;
  #breed;

  constructor(name, breed, age) {
    super('Canine', age);
    this.#name = name;
    this.#breed = breed;
  }

  // Cannot access parent's private fields
  getInfo() {
    // return this.#species; // SyntaxError
    return `${this.#name} is a ${this.#breed}, ${super.getInfo()}`;
  }

  // Own private method
  #bark() {
    return 'Woof!';
  }

  speak() {
    return this.#bark();
  }
}

const dog = new Dog('Max', 'Golden Retriever', 3);
console.log(dog.getInfo()); // Max is a Golden Retriever, Canine, 3 years old
console.log(dog.speak()); // Woof!
dog.birthday();
console.log(dog.age); // 4

Private Fields with Symbols

class SecureStorage {
  // Combine private fields with symbols for extra security
  #data = new Map();
  #encryptionKey = Symbol('encryption-key');
  #accessLog = [];

  constructor() {
    this[this.#encryptionKey] = this.#generateKey();
  }

  #generateKey() {
    return Math.random().toString(36).substring(2);
  }

  #encrypt(value) {
    // Simple XOR encryption for demonstration
    const key = this[this.#encryptionKey];
    return btoa(value.toString() + key);
  }

  #decrypt(encrypted) {
    const key = this[this.#encryptionKey];
    const decrypted = atob(encrypted);
    return decrypted.substring(0, decrypted.length - key.length);
  }

  #logAccess(action, key) {
    this.#accessLog.push({
      action,
      key,
      timestamp: Date.now(),
    });
  }

  set(key, value) {
    const encrypted = this.#encrypt(value);
    this.#data.set(key, encrypted);
    this.#logAccess('set', key);
  }

  get(key) {
    this.#logAccess('get', key);
    const encrypted = this.#data.get(key);
    return encrypted ? this.#decrypt(encrypted) : undefined;
  }

  getAccessLog() {
    // Return copy to prevent modification
    return [...this.#accessLog];
  }

  // Private field storing sensitive method
  #clearMethod = () => {
    this.#data.clear();
    this.#accessLog = [];
    this[this.#encryptionKey] = this.#generateKey();
  };

  clear() {
    this.#clearMethod();
  }
}

const storage = new SecureStorage();
storage.set('secret', 'password123');
console.log(storage.get('secret')); // 'password123'
console.log(storage.getAccessLog());

Real-World Examples

State Machine with Private Fields

class StateMachine {
  #states = new Map();
  #currentState = null;
  #history = [];
  #transitions = new Map();
  #listeners = new Set();

  constructor(initialState) {
    this.#currentState = initialState;
    this.#history.push({ state: initialState, timestamp: Date.now() });
  }

  #validateTransition(from, to) {
    const key = `${from}->${to}`;
    return this.#transitions.has(key);
  }

  #notifyListeners(oldState, newState) {
    this.#listeners.forEach((listener) => {
      listener({ from: oldState, to: newState, timestamp: Date.now() });
    });
  }

  addState(name, config = {}) {
    this.#states.set(name, {
      name,
      onEnter: config.onEnter || (() => {}),
      onExit: config.onExit || (() => {}),
      data: config.data || {},
    });
    return this;
  }

  addTransition(from, to, condition = () => true) {
    const key = `${from}->${to}`;
    this.#transitions.set(key, { from, to, condition });
    return this;
  }

  transition(to, context = {}) {
    const from = this.#currentState;

    if (!this.#validateTransition(from, to)) {
      throw new Error(`Invalid transition from ${from} to ${to}`);
    }

    const transition = this.#transitions.get(`${from}->${to}`);
    if (!transition.condition(context)) {
      throw new Error(`Transition condition not met`);
    }

    // Execute state lifecycle
    const fromState = this.#states.get(from);
    const toState = this.#states.get(to);

    if (fromState) {
      fromState.onExit(context);
    }

    this.#currentState = to;
    this.#history.push({ state: to, timestamp: Date.now(), context });

    if (toState) {
      toState.onEnter(context);
    }

    this.#notifyListeners(from, to);

    return this;
  }

  onStateChange(listener) {
    this.#listeners.add(listener);
    return () => this.#listeners.delete(listener);
  }

  get state() {
    return this.#currentState;
  }

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

  canTransitionTo(state) {
    return this.#validateTransition(this.#currentState, state);
  }
}

// Usage
const orderStateMachine = new StateMachine('pending');

orderStateMachine
  .addState('pending', {
    onEnter: () => console.log('Order is pending'),
  })
  .addState('processing', {
    onEnter: () => console.log('Processing order'),
    onExit: () => console.log('Finished processing'),
  })
  .addState('shipped', {
    onEnter: (ctx) =>
      console.log(`Shipped with tracking: ${ctx.trackingNumber}`),
  })
  .addState('delivered')
  .addState('cancelled');

orderStateMachine
  .addTransition('pending', 'processing')
  .addTransition('pending', 'cancelled')
  .addTransition('processing', 'shipped')
  .addTransition('processing', 'cancelled')
  .addTransition('shipped', 'delivered');

// State changes
orderStateMachine.transition('processing');
orderStateMachine.transition('shipped', { trackingNumber: '123456' });

Event Emitter with Private Fields

class TypedEventEmitter {
  #events = new Map();
  #maxListeners = 10;
  #asyncHandlers = new WeakMap();

  #validateEventName(event) {
    if (typeof event !== 'string' && typeof event !== 'symbol') {
      throw new TypeError('Event name must be a string or symbol');
    }
  }

  #getHandlers(event) {
    if (!this.#events.has(event)) {
      this.#events.set(event, []);
    }
    return this.#events.get(event);
  }

  on(event, handler) {
    this.#validateEventName(event);

    const handlers = this.#getHandlers(event);

    if (handlers.length >= this.#maxListeners) {
      console.warn(
        `Max listeners (${this.#maxListeners}) exceeded for event "${String(event)}"`
      );
    }

    handlers.push(handler);
    return this;
  }

  once(event, handler) {
    this.#validateEventName(event);

    const wrappedHandler = (...args) => {
      this.off(event, wrappedHandler);
      return handler(...args);
    };

    // Store reference for removal
    this.#asyncHandlers.set(wrappedHandler, handler);

    return this.on(event, wrappedHandler);
  }

  off(event, handler) {
    this.#validateEventName(event);

    const handlers = this.#events.get(event);
    if (!handlers) return this;

    const index = handlers.findIndex(
      (h) => h === handler || this.#asyncHandlers.get(h) === handler
    );

    if (index !== -1) {
      handlers.splice(index, 1);
    }

    if (handlers.length === 0) {
      this.#events.delete(event);
    }

    return this;
  }

  emit(event, ...args) {
    this.#validateEventName(event);

    const handlers = this.#events.get(event);
    if (!handlers || handlers.length === 0) {
      return false;
    }

    // Create a copy to avoid issues if handlers modify the array
    const handlersCopy = [...handlers];

    for (const handler of handlersCopy) {
      try {
        handler(...args);
      } catch (error) {
        console.error(`Error in event handler for "${String(event)}":`, error);
      }
    }

    return true;
  }

  async emitAsync(event, ...args) {
    this.#validateEventName(event);

    const handlers = this.#events.get(event);
    if (!handlers || handlers.length === 0) {
      return [];
    }

    const results = await Promise.allSettled(
      handlers.map((handler) => Promise.resolve(handler(...args)))
    );

    return results;
  }

  removeAllListeners(event) {
    if (event) {
      this.#validateEventName(event);
      this.#events.delete(event);
    } else {
      this.#events.clear();
    }
    return this;
  }

  listenerCount(event) {
    this.#validateEventName(event);
    return this.#events.get(event)?.length || 0;
  }

  eventNames() {
    return Array.from(this.#events.keys());
  }

  setMaxListeners(n) {
    if (typeof n !== 'number' || n < 0) {
      throw new RangeError('Max listeners must be a non-negative number');
    }
    this.#maxListeners = n;
    return this;
  }
}

// Usage
const emitter = new TypedEventEmitter();

emitter.on('data', (data) => console.log('Received:', data));
emitter.once('error', (error) => console.error('Error:', error));

emitter.emit('data', { id: 1, value: 'test' });
emitter.emit('error', new Error('Test error'));
emitter.emit('error', new Error("This won't be logged"));

Best Practices

  1. Declare private fields at the top
class Good {
  // All private fields declared upfront
  #privateField1;
  #privateField2 = 'default';

  constructor() {
    this.#privateField1 = 'value';
  }
}
  1. Use private methods for internal logic
class Service {
  #data = [];

  // Private method for validation
  #validateData(item) {
    return item && typeof item === 'object' && item.id;
  }

  // Public method uses private validation
  add(item) {
    if (!this.#validateData(item)) {
      throw new Error('Invalid item');
    }
    this.#data.push(item);
  }
}
  1. Provide controlled access through public methods
class SecureClass {
  #sensitiveData;

  // Don't expose private fields directly
  // BAD: getSensitiveData() { return this.#sensitiveData; }

  // GOOD: Return processed or partial data
  getSafeData() {
    return {
      preview: this.#sensitiveData.substring(0, 4) + '****',
      length: this.#sensitiveData.length,
    };
  }
}
  1. Use private static members for class-level privacy
class Registry {
  static #instances = new Map();

  static register(key, instance) {
    this.#instances.set(key, instance);
  }

  static get(key) {
    return this.#instances.get(key);
  }
}

Conclusion

Private fields and methods provide true encapsulation in JavaScript classes, offering genuine privacy that cannot be breached from outside the class. The # syntax clearly distinguishes private members from public ones, making code more maintainable and secure. While they have some limitations with inheritance and dynamic access, private fields are essential for building robust object-oriented JavaScript applications with proper encapsulation. As the feature becomes more widely supported, it's increasingly important for JavaScript developers to understand and utilize private class fields effectively.