JavaScript Private Fields and Static Members: True Encapsulation with #name and static
For most of JavaScript's history, "private" was a convention — a leading underscore (_count), a closure, or a WeakMap. None of them were truly enforced. ES2022 added private fields (the #name syntax) that the engine itself enforces. Combined with static members, modern JavaScript classes finally have real encapsulation.
This lesson covers private instance fields, private methods, the matching static features, and static blocks for one-time class initialization. After Lesson 4.2 (Classes Explained), this is the lesson that gives you the encapsulation tools that older codebases had to fake.
Private fields with # #
A field prefixed with # is accessible only inside its declaring class:
class Counter {
#count = 0;
increment() {
this.#count++;
}
value() {
return this.#count;
}
}
const c = new Counter();
c.increment();
c.value(); // 1
c.#count; // SyntaxError — can't access from outside
The # is part of the name. #count and count are different names. The privacy is enforced at parse time — it's not a runtime convention you can Reflect-around.
Why this matters #
The older patterns:
| Pattern | Problem |
|---|---|
_count underscore |
Convention only. Anyone can read/write it. |
| Closure | Methods declared per-instance, more memory, awkward syntax. |
WeakMap keyed by instance |
Verbose. Hard to debug. |
Symbol-keyed properties |
Discoverable via Object.getOwnPropertySymbols. |
Object.defineProperty({ enumerable: false }) |
Hidden from iteration but still readable. |
All of those leak. #field doesn't.
Private fields are per-class, not per-instance #
A subclass cannot access a parent's private field:
class Animal {
#name;
constructor(name) { this.#name = name; }
describe() { return `Animal: ${this.#name}`; }
}
class Dog extends Animal {
constructor(name) {
super(name);
}
describe() {
return `Dog ${this.#name}`; // SyntaxError
}
}
If you need shared access, expose a protected accessor (a method or getter) on the parent.
Private methods #
The # prefix works for methods too:
class User {
constructor(name, password) {
this.name = name;
this.#password = this.#hash(password);
}
#password;
#hash(s) {
return [...s].reverse().join(''); // toy example
}
authenticate(input) {
return this.#hash(input) === this.#password;
}
}
Private methods are useful for helpers you don't want to expose. Equivalent in effect to file-level helper functions in a module, but kept inside the class for cohesion.
When to use private fields #
The rule of thumb: make fields private by default; expose what you intend as API.
Three practical scenarios:
1. Implementation details #
Cache state, parsed inputs, internal counters — anything callers shouldn't reach into:
class RateLimiter {
#recentCalls = [];
#limit;
constructor(limit) {
this.#limit = limit;
}
allow() {
const now = Date.now();
this.#recentCalls = this.#recentCalls.filter(t => now - t < 60_000);
if (this.#recentCalls.length >= this.#limit) return false;
this.#recentCalls.push(now);
return true;
}
}
Callers can't accidentally mutate recentCalls or break the limiter's invariants.
2. Validated state #
When a property must satisfy invariants:
class Account {
#balance = 0;
deposit(n) {
if (n <= 0) throw new Error('Invalid amount');
this.#balance += n;
}
withdraw(n) {
if (n > this.#balance) throw new Error('Insufficient funds');
this.#balance -= n;
}
get balance() { return this.#balance; } // read-only public access
}
The public balance is read-only. Modification only happens through validated methods.
3. Sensitive data #
Tokens, passwords, decrypted credentials — anything that should not show up in JSON.stringify(instance), console logs of the object, or third-party libraries that walk properties.
Private fields don't appear in object spread, Object.keys, JSON.stringify, or the inspector's default view of an instance.
Static private fields #
A private field can also be static:
class Logger {
static #defaultLevel = 'info';
static #seenIds = new Set();
static log(id, msg) {
if (this.#seenIds.has(id)) return; // dedupe
this.#seenIds.add(id);
console[this.#defaultLevel](msg);
}
}
Nice for class-level state that should not be reachable from outside.
Static methods recap #
We covered the basic static keyword in Lesson 4.2. To recap:
class MathUtil {
static square(n) { return n * n; }
static PI = 3.14159;
}
MathUtil.square(5); // 25
MathUtil.PI; // 3.14159
Useful for factories, utilities, and class-level constants.
Static methods inheriting #
Static methods are inherited too:
class Animal {
static create(name) {
return new this(name); // `this` is whichever subclass was called
}
}
class Dog extends Animal {
constructor(name) {
super();
this.name = name;
}
}
Dog.create('Rex'); // creates a Dog, not an Animal
this inside a static method refers to the class it was called on. Dog.create runs with this === Dog, so new this(name) is new Dog(name). Useful for factory patterns.
Static blocks #
For more complex static initialization, use static {}:
class Config {
static defaults;
static featureFlags;
static {
try {
const raw = readFileSync('config.json', 'utf8');
Config.defaults = JSON.parse(raw);
} catch {
Config.defaults = {};
}
Config.featureFlags = { ...Config.defaults.flags };
}
}
A static block runs once when the class is defined. It has access to private fields, static and instance, and runs in the order it appears.
Multiple static blocks run in declaration order:
class Logger {
static #cache;
static prefix;
static { Logger.#cache = new Map(); } // 1st
static { Logger.prefix = '[LOG]'; } // 2nd
}
Useful when initialization requires logic — try/catch, conditional defaults, computed values — instead of a single expression.
A complete example: a self-contained cache #
class Cache {
static #instances = new Map();
#store = new Map();
#ttl;
constructor(ttlMs) {
this.#ttl = ttlMs;
}
static get(name, ttlMs = 60_000) {
if (!Cache.#instances.has(name)) {
Cache.#instances.set(name, new Cache(ttlMs));
}
return Cache.#instances.get(name);
}
set(key, value) {
this.#store.set(key, { value, expires: Date.now() + this.#ttl });
}
get(key) {
const entry = this.#store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expires) {
this.#store.delete(key);
return undefined;
}
return entry.value;
}
}
// Usage
const users = Cache.get('users');
users.set(1, { name: 'Ada' });
users.get(1); // { name: 'Ada' }
users.#store; // SyntaxError — truly private
Everything internal is #. Everything callers should use is public. The class is its own black box.
A practical privacy checklist #
When writing a class:
- Make fields
#private unless the API needs them public. - Expose read-only state with a public getter.
- Validate mutations through public methods, not by exposing raw fields.
- Helpers that callers don't need?
#them. - Class-level state (shared across instances)?
static #field. - Complex initialization at class definition time?
static {}block.
Most codebases drift toward over-exposed classes simply because privacy used to be hard. With # it's one character. Use it.
What's next #
Lesson 4.5 covers mixins and composition — what to reach for when inheritance is the wrong tool. Combined with Lessons 4.1–4.4, you'll have the full OOP toolkit for modern JavaScript.
Try it yourself #
Private fields' enforcement is sharper than people expect. Predict what happens:
class C { #x = 1; show() { return this.#x; } }
const c = new C();
c.show();
c.x = 99;
console.log(c.show());
console.log(JSON.stringify(c));js_sandboxOutput:•
c.show() → 1.•
c.x = 99 creates a new public property — #x and x are different names.•
c.show() → 1. The private #x is unchanged.•
JSON.stringify(c) → {"x":99}. Private fields are skipped by JSON; only the public x we just created shows up.Private and public are completely separate namespaces. That’s the whole encapsulation story in one experiment.
If you've spent years working around the lack of true private members in JavaScript, this single feature is worth the upgrade by itself.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


