loke.dev
Header image for Stop Using the `private` Keyword: Why Native `#private` Fields Are the Only Real Way to Encapsulate Your TypeScript Classes

Stop Using the `private` Keyword: Why Native `#private` Fields Are the Only Real Way to Encapsulate Your TypeScript Classes

The `private` modifier is a compile-time illusion that disappears at runtime, potentially leaking state and missing V8 optimizations—here is why your classes need the hard encapsulation of native `#private` fields.

· 4 min read

For years, we’ve been leaning on TypeScript’s private modifier to protect our class internals, trusting the compiler to keep our dirty secrets safe. But here is the uncomfortable truth: TypeScript’s private is a polite suggestion, not a security boundary, and at runtime, your "private" data is as public as a billboard in Times Square.

If you’re still writing private name: string, you’re essentially using a "Keep Out" sign made of wet tissue paper. If you want actual, hardened encapsulation, it's time to embrace the native #private field syntax.

The Lie We’ve Been Living

When you mark a property as private in TypeScript, the compiler checks for unauthorized access during development. It feels great. It looks clean. But the moment that code hits the browser or Node.js, the private keyword evaporates.

Because JavaScript (until recently) had no native concept of private properties, TypeScript just transpiles it into a regular, public property.

class BankAccount {
  private balance: number;

  constructor(initialAmount: number) {
    this.balance = initialAmount;
  }
}

const account = new BankAccount(1000);

// TypeScript yells at me: "Property 'balance' is private..."
// account.balance = 0; 

// But JavaScript doesn't care. I can just do this:
(account as any).balance = 0; 

// Or even worse, the bracket notation hack:
account['balance'] = 999999;

In a large codebase, or when building a library for others, this "soft" privacy is a liability. It allows consumers to bypass your logic, mutate state they shouldn't touch, and create bugs that are nightmares to track down.

The # Revolution: Hard Encapsulation

Native private fields (introduced in ES2020) change the game. By prefixing a property name with #, you are telling the JavaScript engine itself—not just the TypeScript compiler—that this variable is strictly off-limits.

class SecureVault {
  #code: number;

  constructor(code: number) {
    this.#code = code;
  }

  unlock(attempt: number) {
    return this.#code === attempt;
  }
}

const vault = new SecureVault(1234);

// This is a hard "No."
// It won't just fail at compile time; it throws a SyntaxError at runtime.
console.log(vault.#code); // Property '#code' is not accessible outside class 'SecureVault'

With #private fields, you can’t use as any to cheat. You can’t use bracket notation. The data is truly encapsulated. It’s not just a pinky promise from the compiler anymore; it's a physical wall.

Subclass Collisions (The Silent Killer)

One of my favorite reasons to ditch the private keyword is how native fields handle inheritance. In TypeScript, if a base class and a subclass both use the same private property name, things can get weird and messy because they both map to the same property name on the underlying object.

With #private fields, names are unique to the class they are defined in. You can have the same field name in a subclass, and they will never collide.

class Parent {
  #id = "parent-id";

  printId() {
    console.log(this.#id);
  }
}

class Child extends Parent {
  #id = "child-id"; // Totally fine, no collision

  printChildId() {
    console.log(this.#id);
  }
}

const instance = new Child();
instance.printId();      // "parent-id"
instance.printChildId(); // "child-id"

In the old private world, these two would likely fight over the same key in the object's memory. Native fields use a different mechanism (WeakMaps or internal slots, depending on the engine), ensuring that your logic stays isolated.

V8 Performance and Hidden Classes

There is a technical argument for performance here too. Modern JavaScript engines like V8 use "Hidden Classes" (Shapes) to optimize property access. When you use native #private fields, the engine knows the shape of your object is fixed and that these fields cannot be added or deleted dynamically.

While the performance difference is often negligible for your average Todo app, in high-performance loops or complex data structures, native private fields can help the engine stay in the "fast path" for optimization more reliably than dynamic properties ever could.

The "Gotchas" (Because Nothing is Perfect)

I’m not going to pretend it’s all sunshine and rainbows. There are two things you need to watch out for:

1. The Proxy Problem: If you use libraries that rely heavily on Proxy (like some older versions of Vue or certain ORMs), native private fields can be a headache. Since a Proxy isn't the original instance, it can't access private fields on the target without some clever (and often annoying) binding.
2. Targeting Older Browsers: If you are still forced to support Internet Explorer 11 (my condolences), transpiling #private fields involves a lot of heavy polyfilling that can bloat your bundle and slow things down. But if you're on a modern stack (ES2022+), this is a non-issue.

When Should You Switch?

If you are building a library, switch now. You shouldn't trust your users to respect your private modifiers. If you are building a large-scale enterprise application where state integrity is vital, switch now.

The private keyword was a great stopgap while JavaScript was maturing, but the language has grown up. It’s time to stop pretending and start using the real thing. Use # for anything that truly belongs to the class, and save private for... well, honestly, I haven't found a good reason to go back.