Creating Private Class Fields in Modern JS
A complete guide to ES2022 private class fields and methods in JavaScript. Learn the # syntax, private instance fields, private methods, private static fields, the in operator for private field existence checks, and how to migrate from older WeakMap patterns.
ES2022 introduced genuinely private class fields and methods using the # prefix. Unlike the _underscoreConvention, the JavaScript engine enforces # privacy — accessing a private field from outside the class body throws a SyntaxError at parse time, not a runtime error. This makes # fields the strongest encapsulation mechanism in JavaScript.
Declaring Private Instance Fields
Private fields must be declared at the top of the class body before use:
class User {
// Declaration — required before using #password
#password;
#loginAttempts = 0;
constructor(username, password) {
this.username = username; // Public field
this.#password = this.#hash(password); // Private
}
#hash(pwd) {
// Private method — simplified hash for illustration
return `hashed_${pwd}_${pwd.length}`;
}
login(password) {
this.#loginAttempts += 1;
if (this.#loginAttempts > 3) throw new Error("Account locked");
return this.#hash(password) === this.#password;
}
resetPassword(oldPwd, newPwd) {
if (!this.login(oldPwd)) throw new Error("Wrong password");
this.#password = this.#hash(newPwd);
this.#loginAttempts = 0;
}
}
const user = new User("alice", "secret123");
console.log(user.login("secret123")); // true
console.log(user.login("wrong")); // false
// These throw SyntaxError at parse time:
// console.log(user.#password);
// user.#loginAttempts = 0;Private fields:
- Must be declared at the class level (with or without an initializer)
- Cannot be added dynamically —
obj.#field = xoutside the class throws - Are not accessible via bracket notation:
obj["#field"]accesses a different, public field named literally"#field"
Private Methods
Methods can also be made private with #:
class DataFetcher {
#cache = new Map();
#baseUrl;
constructor(baseUrl) {
this.#baseUrl = baseUrl;
}
// Private method
async #fetchRaw(endpoint) {
const response = await fetch(`${this.#baseUrl}${endpoint}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
// Private caching logic
async #fetchCached(endpoint) {
if (this.#cache.has(endpoint)) {
return this.#cache.get(endpoint);
}
const data = await this.#fetchRaw(endpoint);
this.#cache.set(endpoint, data);
return data;
}
// Public API
async getUser(id) {
return this.#fetchCached(`/users/${id}`);
}
async getProduct(id) {
return this.#fetchCached(`/products/${id}`);
}
clearCache() { this.#cache.clear(); }
}
const fetcher = new DataFetcher("https://api.example.com");
// fetcher.#fetchRaw("/users/1") → SyntaxError — privatePrivate Getters and Setters
Getters and setters can be private:
class Circle {
#radius;
constructor(radius) {
this.radius = radius; // Goes through public setter
}
// Public getter/setter
get radius() { return this.#radius; }
set radius(r) {
if (r < 0) throw new RangeError("Radius cannot be negative");
this.#radius = r;
}
// Private computed helpers
get #radiusSquared() { return this.#radius ** 2; }
get area() { return Math.PI * this.#radiusSquared; }
get circumference() { return 2 * Math.PI * this.#radius; }
}
const c = new Circle(5);
console.log(c.area.toFixed(2)); // "78.54"
c.radius = 10;
console.log(c.area.toFixed(2)); // "314.16"
// c.#radiusSquared → SyntaxErrorPrivate Static Fields and Methods
static # combines static and private:
class IdGenerator {
static #nextId = 1;
static #prefix = "item";
static generate() {
return `${IdGenerator.#prefix}_${IdGenerator.#nextId++}`;
}
static reset() {
IdGenerator.#nextId = 1;
}
}
console.log(IdGenerator.generate()); // "item_1"
console.log(IdGenerator.generate()); // "item_2"
console.log(IdGenerator.generate()); // "item_3"
IdGenerator.reset();
console.log(IdGenerator.generate()); // "item_1"
// IdGenerator.#nextId → SyntaxErrorPrivate static fields are useful for class-level state that should not be manipulated externally.
The in Operator for Private Field Existence
ES2022 also enables using in to check if a private field exists on an object, which is useful for type narrowing without exposing the field:
class Animal {
#species;
constructor(species) { this.#species = species; }
static isAnimal(obj) {
return #species in obj; // Returns true only if obj has this private field
}
}
class Plant {
#genus;
constructor(genus) { this.#genus = genus; }
}
const a = new Animal("Dog");
const p = new Plant("Rosa");
console.log(Animal.isAnimal(a)); // true
console.log(Animal.isAnimal(p)); // false
console.log(Animal.isAnimal({})); // falseThis is more reliable than instanceof for brand checking — it cannot be fooled by prototype manipulation.
Private Fields Are Not Inherited
Private fields scoped to a class are not accessible in subclasses:
class Base {
#secret = 42;
getSecret() { return this.#secret; } // Works — inside Base
}
class Child extends Base {
reveal() {
// return this.#secret; → SyntaxError: Private field '#secret' must be declared
return this.getSecret(); // Must use parent's public method
}
}
const c = new Child();
console.log(c.reveal()); // 42This is a deliberate design choice: subclasses must use the parent's public/protected interface, not bypass it.
Migrating From WeakMap Pattern
Before ES2022, WeakMaps were the canonical true-privacy solution. Migration to # is straightforward:
// OLD: WeakMap-based private state
const _balance = new WeakMap();
const _log = new WeakMap();
class OldAccount {
constructor(balance) {
_balance.set(this, balance);
_log.set(this, []);
}
deposit(amount) {
_balance.set(this, _balance.get(this) + amount);
_log.get(this).push({ type: "deposit", amount });
}
get balance() { return _balance.get(this); }
}
// NEW: Private class fields
class NewAccount {
#balance;
#log = [];
constructor(balance) { this.#balance = balance; }
deposit(amount) {
this.#balance += amount;
this.#log.push({ type: "deposit", amount });
}
get balance() { return this.#balance; }
}The # version is cleaner, faster (avoids WeakMap lookups), and is semantically clearer.
Comparison: Private Techniques at a Glance
| Technique | Language Enforced | Works in Classes | Inheritable | ES Version |
|---|---|---|---|---|
_ convention | No | Yes | Yes | Any |
| Closure | Yes | No (factory pattern) | No | Any |
| WeakMap | Yes | Yes | Via public methods | ES6 |
| Symbol key | Semi-private | Yes | Yes | ES6 |
# private field | Yes (SyntaxError) | Yes | No (by design) | ES2022 |
For a broader view of all encapsulation techniques, see encapsulation in JavaScript.
Rune AI
Key Insights
- # fields are enforced at parse time: Accessing obj.#field outside the declaring class is a SyntaxError (not a runtime error) — caught before the code runs
- Private fields must be declared: You cannot add undeclared # fields dynamically; the declaration at the class level is required and serves as documentation
- Private methods use # too: Same syntax, same rules — private helper methods are common and good practice for internal logic extraction
- Subclasses cannot access parent # fields: Intentional isolation — the parent class must provide a method-based interface for subclasses to interact with its private state
- in operator for brand checking: #field in obj inside the class body returns true only if the object has that private field — more reliable than instanceof for duck-type checking
Frequently Asked Questions
Do private fields show up in Object.getOwnPropertyNames?
Can I use private fields in mixins?
What does "the field is not on the object" mean for private fields?
What is the browser/Node.js support for private class fields?
Conclusion
ES2022 private class fields (#) are the definitive encapsulation tool for modern JavaScript. Declared at the class level, enforced by the language parser, invisible to all reflection APIs, and scoped to the defining class only — they satisfy every encapsulation requirement. Combined with getter/setter pairs for controlled public access (covered in encapsulation in JavaScript) and a well-designed class hierarchy using class inheritance, # fields provide production-grade object privacy.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.