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
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.
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.
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
#fieldsinside the constructor only: Writingthis.#field = valuein the constructor without declaring#field;at the class body level causes aSyntaxError. 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 callssuper()gets the#fieldset 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
_conventionand 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_conventionfor security-sensitive data. -
Forgetting that WeakMap privacy depends on module scope: The WeakMap is only private if you never export it. If you export
_privateDatafrom 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.