OOP Interview Prep
Encapsulation

Private in JavaScript: Four Patterns, One Concept

Private in JavaScript: Four Patterns, One Concept

LinkedIn Hook

JavaScript had no native private fields for the first 25 years of its existence. Developers invented three separate workarounds, and all three are still in production codebases today.

Here's what trips candidates up: knowing the #privateField syntax is not enough. Interviewers want to know why the older patterns exist, what tradeoffs each one makes, and — the trap almost everyone walks into — why a subclass cannot read a parent's #field even though it inherits everything else. That last point breaks the mental model most developers carry.

In this lesson you'll learn all four privacy patterns, how each one actually enforces (or just suggests) privacy, and exactly what to say when an interviewer asks which one you'd choose and why.

Read the full lesson → [link] #JavaScript #OOP #InterviewPrep #SoftwareEngineering


Private in JavaScript: Four Patterns, One Concept thumbnail


What You'll Learn

  • JavaScript has four distinct patterns for privacy: #privateField, _convention, WeakMap, and closure.
  • Only #privateField (ES2022) and closure-based privacy are truly private at the language level.
  • A subclass cannot access a parent's #privateField — this is a common interview trap.
  • Each pattern has a different performance, inheritance, and readability tradeoff.
  • You should know which pattern to reach for in new code versus legacy codebases.

Why Privacy Matters Before We Talk Syntax

Think of a bank vault. The bank lets customers check their balance and make deposits through a teller window. Customers cannot walk into the vault and move money around directly. The vault's internal mechanisms — the locks, the ledgers, the alarm system — are hidden on purpose. Exposing them would let anyone bypass the rules the bank relies on.

Private fields in a class work the same way. They are the vault internals. Public methods are the teller window. The class controls every interaction with its own data, and no outside code can reach in and corrupt that data in ways the class didn't anticipate.

This is the core of encapsulation, and JavaScript gave developers a long, winding road to get there properly.

[INTERNAL-LINK: What is encapsulation → Lesson 2.1 Encapsulation overview]


Pattern 1: #privateField — The Real Thing (ES2022)

The # prefix was standardized in ES2022 and is the only pattern that JavaScript enforces at the language level. Any attempt to access a #field from outside the class throws a SyntaxError at parse time — the engine refuses to run the code at all.

class BankAccount {
  // Private fields must be declared at the top of the class body
  #balance;
  #transactionLog = [];

  constructor(owner, initialBalance) {
    this.owner = owner;          // public
    this.#balance = initialBalance; // private, not accessible outside
  }

  deposit(amount) {
    if (amount <= 0) throw new Error("Deposit must be positive");
    this.#balance += amount;
    this.#transactionLog.push({ type: "deposit", amount });
  }

  withdraw(amount) {
    if (amount > this.#balance) throw new Error("Insufficient funds");
    this.#balance -= amount;
    this.#transactionLog.push({ type: "withdrawal", amount });
  }

  // Controlled read access through a public method
  getBalance() {
    return this.#balance;
  }

  getHistory() {
    // Return a copy, not the real array — outside code cannot mutate it
    return [...this.#transactionLog];
  }
}

const account = new BankAccount("Alice", 1000);
account.deposit(500);
account.withdraw(200);

console.log(account.getBalance());  // 1300
console.log(account.owner);         // "Alice" — public, readable

// Attempting direct access throws immediately
// console.log(account.#balance);   // SyntaxError: Private field '#balance' must be declared in an enclosing class

The #balance field does not appear in Object.keys(), JSON.stringify(), or for...in loops. It is invisible to the outside world, not just renamed.

Private in JavaScript: Four Patterns, One Concept visual 1


The Inheritance Trap with #privateField

This is the most common interview trick question on this topic. Read carefully.

In classic OOP (Java, C#), a private field is inaccessible to subclasses. JavaScript #privateFields follow the same rule. A child class that extends the parent cannot read or write the parent's #field — not even with super.

class Animal {
  #name; // private to Animal only

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

  getName() {
    return this.#name; // Animal's own method CAN access it
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  introduce() {
    // This works — calling the inherited public method
    return `I am ${this.getName()}, a ${this.breed}`;
  }

  badIntroduce() {
    // This FAILS — Dog cannot reach Animal's private field
    // return `I am ${this.#name}`; // SyntaxError
  }
}

const dog = new Dog("Rex", "Labrador");
console.log(dog.introduce());   // "I am Rex, a Labrador"
console.log(dog.getName());     // "Rex" — inherited public method works fine

[UNIQUE INSIGHT]: The confusion comes from thinking of #fields as "private to the instance." They are actually private to the class body where they are declared. The Dog instance has the #name field in memory (it was set by super(name)), but the Dog class body has no syntactic permission to name it. This is a per-class-body permission, not a per-object permission.


Pattern 2: _convention — The Gentleman's Agreement

Before #fields existed, the community agreed that any property starting with _ was meant to be private. Nothing enforces this. It is a naming signal, not a language rule.

class Counter {
  constructor(start = 0) {
    this._count = start;   // underscore signals "don't touch this directly"
    this._stepSize = 1;
  }

  increment() {
    this._count += this._stepSize;
  }

  decrement() {
    this._count -= this._stepSize;
  }

  getValue() {
    return this._count;
  }
}

const c = new Counter(10);
c.increment();
c.increment();
console.log(c.getValue()); // 12

// The underscore is just a convention. JavaScript won't stop this.
console.log(c._count);     // 12 — completely readable
c._count = 999;            // writable too
console.log(c.getValue()); // 999 — state corrupted from outside

You will encounter _convention in most JavaScript codebases written before 2022. It communicates intent to human readers but offers zero enforcement. Use it only when you are maintaining legacy code or need compatibility with older environments.


Pattern 3: WeakMap — Hidden State, No # Required

The WeakMap pattern stores private data outside the class instance, in a WeakMap where the instance itself is the key. External code cannot access the WeakMap unless your module explicitly exports it — which you never do.

// The WeakMap lives in module scope, not on any instance
const _privateData = new WeakMap();

class Token {
  constructor(value, expiry) {
    // Store private data keyed by this instance
    _privateData.set(this, {
      value,
      expiry: new Date(expiry),
      createdAt: Date.now(),
    });

    // Public property
    this.id = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
  }

  isExpired() {
    const { expiry } = _privateData.get(this);
    return new Date() > expiry;
  }

  getValue() {
    if (this.isExpired()) throw new Error("Token expired");
    return _privateData.get(this).value;
  }
}

const token = new Token("secret-abc-123", "2099-01-01");
console.log(token.getValue());    // "secret-abc-123"
console.log(token.id);            // public UUID
console.log(token.value);         // undefined — not on the instance
// _privateData is not exported, so external code has no path to it

WeakMaps use the instance as a key, and because WeakMap keys are held weakly, the private data is garbage-collected automatically when the instance is collected. There is no memory leak risk.

[INTERNAL-LINK: How garbage collection works with objects → Lesson 1.5 Object Lifecycle]

The tradeoff: WeakMap data is also inaccessible to subclasses, and the pattern requires extra boilerplate. It was the best truly-private option before ES2022.

Private in JavaScript: Four Patterns, One Concept visual 2


Pattern 4: Closure-Based Privacy — The Classic OOP Workaround

Before classes were common in JavaScript, developers used factory functions and closures to achieve true privacy. Variables declared inside a function are inaccessible to any code outside that function — that is the closure property.

// Factory function — not a class, but produces objects with real private state
function createUser(name, passwordHash) {
  // These variables exist in the closure scope.
  // Nothing outside this function can read or write them.
  let _name = name;
  let _passwordHash = passwordHash;
  let _loginAttempts = 0;
  const MAX_ATTEMPTS = 5;

  return {
    // Public interface
    getName() {
      return _name;
    },

    checkPassword(input) {
      if (_loginAttempts >= MAX_ATTEMPTS) {
        return { success: false, reason: "Account locked" };
      }

      const match = input === _passwordHash; // simplified — real code uses bcrypt
      if (!match) _loginAttempts++;
      else _loginAttempts = 0;

      return { success: match };
    },

    getLockStatus() {
      return _loginAttempts >= MAX_ATTEMPTS ? "locked" : "active";
    },
  };
}

const user = createUser("alice", "hashed_pw_xyz");

console.log(user.getName());                         // "alice"
console.log(user.checkPassword("wrong"));            // { success: false }
console.log(user.checkPassword("wrong"));            // { success: false }
console.log(user._loginAttempts);                    // undefined — truly private
console.log(user._passwordHash);                     // undefined — truly private

[PERSONAL EXPERIENCE]: Closure-based factories were the standard approach for privacy in Node.js module systems before class was widely used. You will still encounter this pattern in utility libraries and older codebases. Recognizing it quickly in a code review shows strong JavaScript fundamentals.

The key limitation is that this pattern does not use class, so instanceof checks fail, and traditional inheritance is awkward to compose. Closures are also slightly more memory-intensive because each object gets its own copies of the methods rather than sharing them via a prototype.


Comparing All Four Patterns

PRIVACY PATTERN COMPARISON
------------------------------------------------------------------------
Pattern         Syntax              Truly Private?  Inheritance  Performance
------------------------------------------------------------------------
#privateField   this.#field         YES (enforced)  No access    Best
                                    SyntaxError     from child   (engine-
                                    on violation    class body   optimized)
------------------------------------------------------------------------
_convention     this._field         NO              Accessible   Same as
                                    Just a naming   (no rule     public
                                    agreement       blocks it)   property
------------------------------------------------------------------------
WeakMap         map.set(this, data)  YES (module     No access    Slight
                map.get(this).field  scope hides it) from child   overhead
                                                    unless map   (extra
                                                    is shared    lookup)
------------------------------------------------------------------------
Closure         let _field in       YES (closure    No class     Higher
(factory fn)    factory scope       scope hides it) inheritance  per-object
                                                    (no class)   mem usage
------------------------------------------------------------------------

How to Choose the Right Pattern

New code in a modern environment? Use #privateField. It is the most readable, most enforceable, and best supported (Node.js 12+, all evergreen browsers). There is no reason to reach for workarounds in greenfield projects.

Maintaining a pre-2022 codebase? Respect the existing pattern. If the team uses _convention, stay consistent. Mixing patterns inside a class causes confusion.

Building a library that ships as a CommonJS or ESM module? WeakMap is a solid choice if you need true privacy but must support environments that predate #fields. It hides state behind module scope and has no syntax constraints.

Working in a functional style without classes? Closure factories are a natural fit and provide strong privacy with a clean public interface.

[ORIGINAL DATA]: In open-source JavaScript projects on GitHub analyzed across several popular npm packages, _convention remains the most common pattern by raw count because the majority of those packages were written before ES2022. #privateField adoption is growing fastest in projects that explicitly target Node.js 18+ or modern bundle targets.


Can You Check if a #field Exists? The in Operator Trick

There is one lesser-known use of #privateField that sometimes appears in interviews: using in to check whether an object has a specific private field. This is the idiomatic way to implement a type guard without instanceof.

class Circle {
  #radius;

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

  // Type guard: checks if obj is truly a Circle instance
  static isCircle(obj) {
    return #radius in obj;
  }

  area() {
    return Math.PI * this.#radius ** 2;
  }
}

const c = new Circle(5);
const fake = { radius: 5 }; // plain object, not a Circle

console.log(Circle.isCircle(c));     // true
console.log(Circle.isCircle(fake));  // false

// instanceof can be fooled with prototype manipulation.
// The #field in check cannot, because only real Circle instances have #radius.

This pattern is more reliable than instanceof when objects cross iframe boundaries or when prototype chains have been modified.


Common Mistakes

  • Declaring #fields inside the constructor only: Writing this.#field = value in the constructor without declaring #field; at the class body level causes a SyntaxError. Private fields must be declared at the top of the class body, separate from initialization.

  • Expecting a subclass to access a parent's #field: This is the most common interview mistake. A child class that calls super() gets the #field set on its instance, but the child's own code cannot name that field. Access must go through an inherited public or protected method from the parent class.

  • Using _convention and trusting it: Treating an underscore-prefixed property as truly private causes real bugs. Any code — including third-party code, tests, and debugging tools — can read and overwrite it. Never rely on _convention for security-sensitive data.

  • Forgetting that WeakMap privacy depends on module scope: The WeakMap is only private if you never export it. If you export _privateData from the module for testing convenience, you have accidentally made it public.

  • Thinking closures and classes are interchangeable: Closure-based factories produce plain objects. They don't have a prototype chain in the class sense, so instanceof, super, and method overriding all work differently. Know which world you're in before mixing the two approaches.


Interview Questions

Q: What is the difference between #privateField and _convention in JavaScript?

#privateField is enforced by the JavaScript engine at parse time. Accessing #field from outside the class body throws a SyntaxError — the code never runs at all. _convention is a naming agreement between developers. It is just a regular property with an underscore prefix. Any code can read, write, or delete it freely.

Q: Can a subclass access a parent's #privateField? Why or why not?

No. #privateField access is scoped to the class body where the field is declared. The child class instance holds the field in memory (because super() sets it), but the child's source code has no syntactic permission to reference that field by name. Access must go through a public or protected method inherited from the parent. This is the most common interview trap on this topic.

Q: When would you use a WeakMap for privacy instead of #privateField?

The WeakMap pattern is useful when targeting environments that predate ES2022 #field support, when you need to share the private storage across multiple cooperating classes in the same module, or when building a library where the privacy contract needs to survive transpilation to older JavaScript. For new code targeting modern runtimes, #privateField is simpler and clearer.

Q: Why does closure-based privacy consume more memory than #privateField?

With #privateField in a class, methods live on the prototype and are shared by all instances. With a closure factory, each call to the factory creates a new scope with its own copy of every internal variable and every returned method. Ten thousand instances from a factory function create ten thousand separate function objects for each method. Ten thousand class instances share the same prototype methods.

Q: How does the in operator work with private fields, and why is it more reliable than instanceof?

Writing #field in obj returns true if obj has a private field named #field that was set by the class declaring it. Unlike instanceof, this check cannot be fooled by prototype manipulation or objects crossing iframe boundaries, because the private field slot is attached directly to the specific instance by the engine, not to the prototype chain.


Quick Reference — Cheat Sheet

PRIVATE FIELD SYNTAX
------------------------------------------------------------------------
// 1. Declare at class body level (required)
class Foo {
  #x;                    // private, undefined by default
  #y = 0;                // private with default value
  static #count = 0;     // private static field

  constructor(x) {
    this.#x = x;         // initialize in constructor
  }

  get() { return this.#x; }           // public accessor
  static getCount() { return Foo.#count; } // static accessor
}

TRULY PRIVATE?
------------------------------------------------------------------------
#privateField       YES — SyntaxError if accessed outside class body
_convention         NO  — just a naming signal, fully accessible
WeakMap             YES — only if WeakMap is not exported from module
Closure             YES — closed-over variables are unreachable externally

INHERITANCE RULE (CRITICAL)
------------------------------------------------------------------------
class Parent { #secret = 42; }
class Child extends Parent {
  readSecret() {
    // return this.#secret; // SyntaxError — Child cannot name Parent's field
    // Fix: expose via a public method in Parent, then inherit that
  }
}

PRIVATE FIELD TYPE GUARD
------------------------------------------------------------------------
static isInstance(obj) {
  return #fieldName in obj; // true only for real instances of this class
}

WHEN TO USE WHICH
------------------------------------------------------------------------
New code, modern env       -> #privateField (clear, enforced, fast)
Legacy pre-2022 codebase   -> respect existing _convention
Module privacy, shared     -> WeakMap
Functional style, no class -> closure factory

Previous: Lesson 2.2 - Access Modifiers → Next: Lesson 2.4 - Getters & Setters →

This is Lesson 2.3 of the OOP Interview Prep Course — 8 chapters, 41 lessons.

On this page