Skip to content
Codeloom
JavaScript

Private Class Fields (#) and Static Blocks in JavaScript

Learn JavaScript private class fields with #, static blocks, private methods, and how they compare to closures and WeakMap patterns.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • Declaring and using private fields with the # syntax
  • Private methods, static private fields, and accessors
  • Class static initialization blocks for complex setup

Prerequisites

  • JavaScript classes and constructors
  • Basic OOP concepts

JavaScript classes now support truly private fields and methods using the # prefix. Unlike conventions like _privateField, these are enforced by the language — accessing them from outside the class throws a SyntaxError. Combined with static blocks, they give you complete control over class encapsulation.

Private instance fields

Prefix a field name with # to make it private.

class BankAccount {
  #balance = 0;
  #owner;

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

  deposit(amount) {
    if (amount <= 0) throw new Error("Amount must be positive");
    this.#balance += amount;
    return this.#balance;
  }

  withdraw(amount) {
    if (amount > this.#balance) throw new Error("Insufficient funds");
    this.#balance -= amount;
    return this.#balance;
  }

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

  toString() {
    return `${this.#owner}'s account: $${this.#balance}`;
  }
}

const account = new BankAccount("Alice", 1000);
console.log(account.balance);   // 1000 (via getter)
account.deposit(500);
console.log(account.balance);   // 1500

// Cannot access private fields from outside
// account.#balance;  // SyntaxError
// account.#owner;    // SyntaxError

// Not visible in enumeration
console.log(Object.keys(account));          // []
console.log(JSON.stringify(account));       // "{}"

Private fields are not just a naming convention — they are a hard language guarantee. The # is part of the name itself, and the field is only accessible within the class body.

Private methods

Methods can also be private.

class PasswordValidator {
  #minLength;
  #requireSpecialChar;

  constructor(options = {}) {
    this.#minLength = options.minLength || 8;
    this.#requireSpecialChar = options.requireSpecialChar ?? true;
  }

  #hasMinLength(password) {
    return password.length >= this.#minLength;
  }

  #hasSpecialChar(password) {
    return /[!@#$%^&*(),.?":{}|<>]/.test(password);
  }

  #hasUpperCase(password) {
    return /[A-Z]/.test(password);
  }

  #hasNumber(password) {
    return /\d/.test(password);
  }

  validate(password) {
    const errors = [];

    if (!this.#hasMinLength(password)) {
      errors.push(`Must be at least ${this.#minLength} characters`);
    }
    if (!this.#hasUpperCase(password)) {
      errors.push("Must contain an uppercase letter");
    }
    if (!this.#hasNumber(password)) {
      errors.push("Must contain a number");
    }
    if (this.#requireSpecialChar && !this.#hasSpecialChar(password)) {
      errors.push("Must contain a special character");
    }

    return { valid: errors.length === 0, errors };
  }
}

const validator = new PasswordValidator({ minLength: 10 });
console.log(validator.validate("short"));
// { valid: false, errors: ["Must be at least 10 characters", ...] }

console.log(validator.validate("SecurePass1!"));
// { valid: true, errors: [] }

Private accessors (getters/setters)

You can make getters and setters private too.

class Temperature {
  #celsius;

  constructor(celsius) {
    this.#celsius = celsius;
  }

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

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

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

  get fahrenheit() {
    return this.#fahrenheit; // uses private getter
  }

  display() {
    return `${this.#celsius}C / ${this.#fahrenheit}F`;
  }
}

const temp = new Temperature(100);
console.log(temp.display()); // "100C / 212F"

Static private fields

Private fields can be static, shared across all instances but only accessible within the class.

class DatabaseConnection {
  static #instance = null;
  static #connectionCount = 0;

  #id;
  #connected = false;

  constructor(config) {
    DatabaseConnection.#connectionCount++;
    this.#id = DatabaseConnection.#connectionCount;
    this.config = config;
  }

  static getInstance(config) {
    if (!DatabaseConnection.#instance) {
      DatabaseConnection.#instance = new DatabaseConnection(config);
    }
    return DatabaseConnection.#instance;
  }

  static getConnectionCount() {
    return DatabaseConnection.#connectionCount;
  }

  connect() {
    this.#connected = true;
    console.log(`Connection #${this.#id} established`);
  }
}

const db1 = DatabaseConnection.getInstance({ host: "localhost" });
const db2 = DatabaseConnection.getInstance({ host: "remote" });
console.log(db1 === db2); // true (singleton)
console.log(DatabaseConnection.getConnectionCount()); // 1

Class static initialization blocks

Static blocks run once when the class is evaluated, giving you a place for complex initialization logic.

class Config {
  static #settings;
  static #environment;

  static {
    // Complex initialization that cannot be done inline
    try {
      Config.#settings = JSON.parse(
        typeof process !== "undefined"
          ? process.env.APP_CONFIG || "{}"
          : "{}"
      );
    } catch {
      Config.#settings = {};
    }

    Config.#environment =
      Config.#settings.env || (typeof window !== "undefined" ? "browser" : "node");
  }

  static get(key) {
    return Config.#settings[key];
  }

  static get environment() {
    return Config.#environment;
  }
}

console.log(Config.environment); // "browser" or "node"

Multiple static blocks

You can have multiple static blocks. They run in order of appearance.

class Registry {
  static #items = new Map();
  static #initialized = false;

  static {
    // First block: register defaults
    Registry.#items.set("format", "json");
    Registry.#items.set("timeout", 5000);
    Registry.#items.set("retries", 3);
  }

  static {
    // Second block: override from environment
    if (typeof process !== "undefined" && process.env.TIMEOUT) {
      Registry.#items.set("timeout", parseInt(process.env.TIMEOUT));
    }
    Registry.#initialized = true;
  }

  static get(key) {
    return Registry.#items.get(key);
  }

  static isInitialized() {
    return Registry.#initialized;
  }
}

console.log(Registry.get("timeout")); // 5000
console.log(Registry.isInitialized()); // true

Checking for private fields with in

You can use the in operator to check if an object has a private field. This enables brand checking.

class Color {
  #red;
  #green;
  #blue;

  constructor(r, g, b) {
    this.#red = r;
    this.#green = g;
    this.#blue = b;
  }

  static isColor(obj) {
    return #red in obj; // brand check
  }

  equals(other) {
    if (!Color.isColor(other)) {
      throw new TypeError("Expected a Color instance");
    }
    return (
      this.#red === other.#red &&
      this.#green === other.#green &&
      this.#blue === other.#blue
    );
  }

  toString() {
    return `rgb(${this.#red}, ${this.#green}, ${this.#blue})`;
  }
}

const red = new Color(255, 0, 0);
console.log(Color.isColor(red));       // true
console.log(Color.isColor({ r: 255 })); // false

Private fields vs older patterns

Before: WeakMap pattern

const _balance = new WeakMap();

class OldAccount {
  constructor(balance) {
    _balance.set(this, balance);
  }

  getBalance() {
    return _balance.get(this);
  }
}

Before: Closure pattern

function createAccount(initialBalance) {
  let balance = initialBalance; // truly private via closure

  return {
    deposit(amount) { balance += amount; },
    getBalance() { return balance; },
  };
}

Now: Private fields

class Account {
  #balance;

  constructor(balance) {
    this.#balance = balance;
  }

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

Private fields combine the true privacy of closures with the familiar class syntax and the performance of regular property access.

Inheritance and private fields

Private fields are not inherited by subclasses. Each class has its own private namespace.

class Animal {
  #name;

  constructor(name) {
    this.#name = name;
  }

  getName() {
    return this.#name;
  }
}

class Dog extends Animal {
  #breed;

  constructor(name, breed) {
    super(name);
    this.#breed = breed;
    // this.#name;  // SyntaxError -- cannot access parent's private field
  }

  describe() {
    return `${this.getName()} is a ${this.#breed}`;
  }
}

const dog = new Dog("Rex", "Labrador");
console.log(dog.describe()); // "Rex is a Labrador"

The subclass must use the parent’s public or protected interface to access parent data.

Summary

  • Private fields (#field) provide true encapsulation enforced by the language.
  • Private methods (#method()) keep implementation details hidden.
  • Static private fields share data across instances while keeping it inaccessible externally.
  • Static blocks enable complex class initialization logic.
  • Use #field in obj for brand checking (verifying an object is a genuine instance).
  • Private fields are not inherited — subclasses use the parent’s public API.
  • This replaces older patterns (WeakMap, closures, underscore conventions) with cleaner, faster syntax.

Private class fields are one of the most impactful additions to JavaScript classes. They eliminate the need for workarounds and give you genuine encapsulation that the language enforces at the syntax level.