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.
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
- Declare private fields at the top
class Good {
// All private fields declared upfront
#privateField1;
#privateField2 = 'default';
constructor() {
this.#privateField1 = 'value';
}
}
- 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);
}
}
- 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,
};
}
}
- 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.