Learn JS Series (#41) - Private Fields (#) and True Encapsulation

Words
2983
Reading
14 min
Listen
Play
3h

Learn JS Series (#41) - Private Fields (#) and True Encapsulation

js-banner.png

What will I learn

  • You will learn the # syntax for genuinely private class fields and methods;
  • how private fields differ from the old underscore convention (real privacy versus a polite request);
  • how to combine private fields with getters and setters for controlled access;
  • private static members, and the #field in obj brand check;
  • how this finally gives classes the real encapsulation closures gave us back in Phase 2.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Node.js (20+) distribution, or just a modern browser console;
  • Episodes 1-40 read, especially classes, getters/setters, and closures (episode 17).

Difficulty

  • Intermediate

Curriculum (of the Learn JS Series):

Learn JS Series (#41) - Private Fields (#) and True Encapsulation

Last episode we climbed one level up from the individual object and looked at what the class as a whole knows and does: static counters, static factories, static constants. Today we go in the opposite direction and lock something DOWN. For thirty-something episodes now, every piece of data we have hung on an object has been wide open to the world -- anyone with a reference to the object could read it, change it, or quietly corrupt it. We papered over that with a naming convention (the humble leading underscore) and a lot of good faith. Well, good faith is a lovely thing between friends, but it is a terrible thing to build a class API on. Today JavaScript finally gives us the real lock, not the polite sign on the door: the # private field. This is the true encapsulation that closures handed us all the way back in Phase 2, now built right into the class syntax. Let's have a look. ;-)

Solutions to Episode 40 Exercises

Exercise 1 - temperature factories:

class Temperature {
  constructor(celsius) { this.celsius = celsius; }
  static fromFahrenheit(f) { return new Temperature((f - 32) * 5 / 9); }
  static fromKelvin(k) { return new Temperature(k - 273.15); }
}
console.log(Temperature.fromFahrenheit(212).celsius.toFixed(1)); // "100.0"
console.log(Temperature.fromKelvin(300).celsius.toFixed(2));     // "26.85"

The insight: static factory methods give you named, readable ways to build an instance from different inputs, without cramming every conversion into one overloaded constructor. Temperature.fromKelvin(300) says exactly what it does; a bare new Temperature(...) with a raw celsius number would not.

Exercise 2 - a static counter:

class Thing {
  static count = 0;
  constructor() { Thing.count += 1; }
}
new Thing(); new Thing();
console.log(Thing.count); // 2

The insight: count belongs to the class, so you write Thing.count, not this.count. Had you written this.count, you would create a fresh per-instance property sitting at 1 on every object, and the shared class-level counter would never budge -- a classic silent bug.

Exercise 3 - a static block table:

class Days {
  static names = {};
  static {
    ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].forEach((n, i) => {
      Days.names[i] = n;
    });
  }
}
console.log(Days.names[3]); // "Wed"

The insight: the static { } block runs exactly once, when the class is defined, so it is the honest place to populate a lookup table with a loop in stead of a one-line field initializer that cannot express real statements.

Now let's give classes something they lacked for a very long time: real, enforced privacy.

The old way: an underscore and a promise

Back in episode 33, when we built getters and setters, we protected an internal value by naming it _balance, with the leading underscore signalling "internal, please do not touch". It was a widely respected convention across the whole JavaScript world -- and it was ONLY a convention. A polite request. Nothing in the language actually stopped code from reaching straight past your getter and mauling the field directly:

class OldAccount {
  constructor() { this._balance = 0; } // "private" by convention only
  deposit(n) { this._balance += n; }
  get balance() { return this._balance; }
}
const acc = new OldAccount();
acc.deposit(100);
console.log(acc._balance); // 100 - nothing stops this! the underscore is just a hint
acc._balance = 999999;     // and nothing stops this either

The underscore is a note on a Post-it, not a lock on a vault. Any code that has the object can read _balance, overwrite it, set it to a string, delete it, whatever it likes. Your validation in the setter? Bypassed entirely, because there is a back door standing wide open right next to the front door you so carefully built. For genuinely hidden state in Phase 2 we used closures (episode 17), which really did hide their variables -- but that meant giving up the class syntax and returning objects from factory functions. So the situation, for years, was a slightly unhappy trade-off: real privacy OR clean class syntax, pick one. Modern JavaScript finally lets you have both.

The # syntax: genuine private fields

A class field or method whose name begins with # is truly private: it can be accessed only from inside the class body. Any attempt to touch it from outside is a hard syntax error, not a quiet undefined -- the language itself refuses to run the code. This is real, enforced privacy, not a gentleman's agreement:

class Account {
  #balance = 0;               // a genuinely private field

  deposit(amount) {
    this.#balance += amount;  // accessible inside the class
    return this.#balance;
  }
  get balance() {
    return this.#balance;     // controlled read access
  }
}

const acc = new Account();
console.log(acc.deposit(100)); // 100
console.log(acc.balance);      // 100 - via the public getter
// console.log(acc.#balance);  // SyntaxError: Private field '#balance' must be declared in an enclosing class

#balance is invisible and unreachable from outside Account. You cannot read it, cannot write it, cannot even detect it with the tricks that exposed the underscore version. It does not show up in Object.keys, it is skipped by JSON.stringify, it never appears in a for...in loop, and Object.getOwnPropertyNames will not list it either. From the outside, it is as if the field simply does not exist -- the only doorway in is the code you wrote inside the class. That is exactly the true encapsulation closures gave us, now wearing the clean, familiar class syntax. The best of both worlds, at last.

The # is part of the name

Here is a mental model worth locking in early, because it explains almost every "wait, why does that not work?" moment people hit with private fields: the # is part of the field's name, not an operator you apply to a normal property. You must declare a private field in the class body before you use it, and you always access it as this.#name. There is no dynamic access -- this["#balance"] does NOT reach it, because #balance is not a string key at all. It lives in a separate, private namespace the language manages for you:

class Counter {
  #count = 0;                 // must be declared here, up front
  increment() { this.#count += 1; return this.#count; }
  // this["#count"] would NOT reach the private field - it is not a string property
}
console.log(new Counter().increment()); // 1
console.log(new Counter().increment()); // 1 (a fresh counter each time)

This is precisely why private fields are so airtight: they are not string-keyed properties hiding under a naming trick, they are genuine private names that outside code cannot even spell. That same design is why accessing an undeclared #field is a compile-time syntax error rather than a runtime undefined: the engine reads every private name in a class up front, and if you reference one it does not know about, it refuses to parse the file at all. Contrast that with the underscore world, where a typo like this._balnace silently created a new junk property and happily returned undefined -- no warning, no error, just a bug waiting to bite you at 2am. Private fields turn that whole category of mistake into an immediate, loud error. I will take loud over silent every single time.

Private methods and combining with accessors

It is not just data -- methods can be private too. A #method is a helper you can call only from inside the class, which is perfect for internal logic you do not want bulging out of your public interface. Keeping the surface small is a genuine kindness to whoever uses your class (often future-you), because every public method is a promise you have to keep working forever; a private one you can rip out and rewrite whenever you like:

class Temperature {
  #celsius;

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

  #toFahrenheit() {          // private helper method
    return this.#celsius * 9 / 5 + 32;
  }

  report() {                 // public method using the private helper
    return `${this.#celsius}C is ${this.#toFahrenheit()}F`;
  }
}

const t = new Temperature(20);
console.log(t.report()); // "20C is 68F"
// t.#toFahrenheit();    // SyntaxError - private method, not callable from outside

Private fields combine naturally with the getters and setters we built in episode 33, and together they form the textbook encapsulation pattern: hide the data completely, then expose one deliberate, validated gate to it. The field is invisible; the setter is the only way in, and it gets to enforce every rule it likes:

class Person {
  #age = 0;
  get age() { return this.#age; }
  set age(value) {
    if (typeof value !== "number" || Number.isNaN(value)) {
      throw new TypeError("age must be a number");
    }
    if (value < 0) throw new RangeError("age cannot be negative");
    this.#age = value;
  }
}
const p = new Person();
p.age = 30;
console.log(p.age); // 30
// p.age = -5;      // throws; and there is NO back door to reach #age directly

Unlike the underscore version, there is genuinely no way around the setter. With _age, someone could always write p._age = -5 and skip your validation entirely, leaving your object in a state you swore could never happen. With #age, the only path to the value runs through the setter's checks, so an invalid Person is now unrepresentable. That is the whole point of encapsulation: not to be secretive for its own sake, but to guarantee that your object can never be put into a broken state by code outside it. The guarantee is what makes the rest of your program easier to reason about.

Private static members and the in check

Private members can be static too -- class-level private state that no outside code (and no other class) can reach. That is exactly what you want for something like an internal ID counter: shared across the class, driving unique IDs, and impossible for anyone to reset from the outside. And alongside it comes a genuinely neat idiom for safely checking whether an object carries one of your private fields: the #field in obj brand check, which asks "is this object a real instance of my class?" without ever risking an error:

class IdGenerator {
  static #nextId = 1;             // private static state
  static generate() { return IdGenerator.#nextId++; }
}
console.log(IdGenerator.generate()); // 1
console.log(IdGenerator.generate()); // 2

class Money {
  #amount;
  constructor(a) { this.#amount = a; }
  static isMoney(obj) {
    return #amount in obj;         // true only for real Money instances
  }
}
console.log(Money.isMoney(new Money(10)));  // true
console.log(Money.isMoney({ amount: 10 })); // false - a lookalike, not a real Money

IdGenerator.#nextId is private class-level state driving a counter that no outside code can peek at or reset, so your IDs stay honest. And #amount in obj safely asks "does this object have my private #amount field?", returning true only for objects genuinely constructed by Money. Note how it correctly rejects the plain object { amount: 10 }, which has a normal amount property but is NOT a real Money. This brand check is, in some ways, more robust than instanceof (which we look at properly next episode), because it cannot be fooled by an object that merely copied your shape -- only your own constructor can install your private field, so the field's presence is a genuine mark of authenticity, a "brand" burned onto real instances.

A quick look sideways: privacy in Python and Rust

Quite some of you arrived at this series from the Learn Python Series, and a short comparison sharpens what JavaScript's # really is by showing you what it is NOT. Python's convention is a single leading underscore (_balance) for "internal", which is exactly JavaScript's old underscore -- a polite request, nothing enforced. Python also has a double leading underscore (__balance), which triggers name mangling: the interpreter quietly renames it to _ClassName__balance. That sounds like privacy, but it is really just obfuscation -- the value is still fully reachable if you know the mangled name:

class Account:
    def __init__(self):
        self.__balance = 0          # name-mangled, NOT truly private
    def deposit(self, n):
        self.__balance += n

acc = Account()
acc.deposit(100)
# print(acc.__balance)             # AttributeError - looks private...
print(acc._Account__balance)       # 100 - ...but the back door is right here

So Python's __ is a speed bump, not a wall. JavaScript's #, by contrast, is a genuine wall -- there is no mangled name to reach for, no reflection trick, nothing. Compiled languages like Rust take yet another approach: privacy there is enforced by the compiler at module boundaries with the pub keyword, and a field with no pub simply cannot be named from outside its module -- the program will not compile. Different mechanism, same goal, and it is worth seeing that JavaScript's runtime-enforced # lands closer to Rust's hard guarantee than to Python's honour system. Three languages, three flavours of the same idea: an object should get to decide what the outside world may touch.

Private fields versus closures

Both closures (episode 17) and private fields deliver true privacy, so a fair question is: which should you actually use? They are two valid tools with different ergonomics, not a right-and-wrong choice. Private fields are the natural fit when you are already writing classes -- they are clean, they play nicely with inheritance and methods, and they keep the private data attached to each instance in the familiar object-oriented shape. Closures are the natural fit for factory functions and the functional style, where you return an object (or a function) that closes over private variables, no class needed at all -- and Phase 10, when we get to functional JavaScript, leans hard that way:

// The closure route to the same privacy, no class in sight
function makeAccount() {
  let balance = 0;                       // private via closure scope
  return {
    deposit(n) { balance += n; return balance; },
    get balance() { return balance; },
  };
}
const a = makeAccount();
a.deposit(100);
console.log(a.balance); // 100
// there is no 'a.balance =' back door, and no way to reach the inner 'balance'

That little factory hides balance every bit as thoroughly as #balance did, purely through the closure scope we studied ages ago -- the returned object is the only thing that can still see the variable. So the guidance is simple: in class-based code, reach for # private fields; in function-based or functional code, reach for closures. Knowing both, and understanding that they are two doors into the same room, means you can pick whichever fits the style of the surrounding code in stead of forcing one everywhere. Having said that, do not mix them pointlessly in a single class -- pick the tool that matches the shape you are already writing, and stay consistent.

A couple of honest caveats

Two things worth flagging before you go sprinkle # everywhere. First, private fields are per-class, not per-instance in the way people sometimes assume: a method of Account can read the #balance of ANY Account instance, not only its own this. That is deliberate and occasionally very useful (think of an equals(other) method comparing two of your own objects), but it surprises people who expect each object to be a sealed box even to its siblings. Second, because private state is genuinely invisible, serializing an object with JSON.stringify will silently skip every # field -- so if you need to save and reload such an object, you have to write your own toJSON-style method that deliberately exposes what should be persisted. Real privacy has a real cost at the boundaries of your program (saving, logging, debugging), and that is a fair trade, but it is a trade you should make with your eyes open.

Try it yourself

  1. Rewrite the Account class from this episode so that deposit rejects negative amounts (throw a clear error) and the balance can only ever be read through the getter. Then prove from outside that you cannot access #balance directly, and note the exact syntax error you would get.
  2. Create a class PasswordBox with a private #value, a public set(value) method, and a public matches(guess) method that returns whether a guess equals the stored value -- without ever exposing the value itself. Confirm there is genuinely no way to read the password from outside the class.
  3. Add a private static counter to a class so that each new instance is assigned a unique, incrementing id (readable via a public getter, but never settable from outside). Create three instances and print their ids, then explain in one sentence how # privacy differs from the old _underscore convention.

So what did we actually cover?

  • Fields and methods named with a leading # are truly private: reachable only inside the class body, and any outside access is a syntax error, not a quiet undefined.
  • This replaces the old _underscore convention, which was only a polite request anyone could ignore; # is enforced by the language itself and is invisible to Object.keys, JSON.stringify, and for...in.
  • The # is part of the name (a separate private namespace), so there is no dynamic this["#x"] access, and referencing an undeclared private field is a compile-time error that catches typos loudly.
  • Private methods (#method) hide internal helpers; combined with getters and setters they build a controlled interface with no back door, making broken object states unrepresentable.
  • Private static members hold class-level private state (like an ID counter), and the #field in obj brand check safely tests for genuine instances -- often more reliable than a shape check.
  • Private fields and closures both give real privacy: reach for # in class-based code and closures in factory or functional code. They are two doors into the same room.

Next episode we look at asking "what kind of object is this?" honestly: instanceof, isPrototypeOf, and the subtle ways those checks can lie to you if you are not careful.

That's it for today, see you in the next one.

scipio@scipio

Learn JS Series (#41) - Private Fields (#) and True Encapsulation | Ecency