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.

JavaScriptintermediate
12 min read

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:

javascriptjavascript
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 = x outside 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 #:

javascriptjavascript
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 — private

Private Getters and Setters

Getters and setters can be private:

javascriptjavascript
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 → SyntaxError

Private Static Fields and Methods

static # combines static and private:

javascriptjavascript
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 → SyntaxError

Private 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:

javascriptjavascript
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({})); // false

This 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:

javascriptjavascript
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()); // 42

This 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:

javascriptjavascript
// 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

TechniqueLanguage EnforcedWorks in ClassesInheritableES Version
_ conventionNoYesYesAny
ClosureYesNo (factory pattern)NoAny
WeakMapYesYesVia public methodsES6
Symbol keySemi-privateYesYesES6
# private fieldYes (SyntaxError)YesNo (by design)ES2022

For a broader view of all encapsulation techniques, see encapsulation in JavaScript.

Rune AI

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
RunePowered by Rune AI

Frequently Asked Questions

Do private fields show up in Object.getOwnPropertyNames?

No. Private fields do not appear in `Object.getOwnPropertyNames()`, `Object.keys()`, or `for...in` loops. They are completely hidden from all reflection APIs.

Can I use private fields in mixins?

Yes, but carefully. If a mixin function returns a class expression with `#fields`, those fields are private to that anonymous class and do not conflict with the outer class or other mixins. Since each class expression is its own scope, field names can repeat across mixins without collision.

What does "the field is not on the object" mean for private fields?

Private fields are stored in the object's internal slots, separate from the property store. They are part of the object but not accessible via property access syntax — they are only accessible via the `this.#field` syntax inside the defining class body.

What is the browser/Node.js support for private class fields?

Private instance fields have full support in all modern browsers (Chrome 74+, Firefox 90+, Safari 14.1+) and Node.js 12+. Private methods and static private fields arrived slightly later but are now universally supported. For legacy environments, transpilers like Babel transform `#` fields into WeakMap-based equivalents.

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.