DEV Community

Cover image for JavaScript Private Elements Trump Compile-Time Illusion
Seth
Seth

Posted on • Originally published at sethaalexander.com

JavaScript Private Elements Trump Compile-Time Illusion

After spending years building systems that demand true encapsulation and security boundaries, I’ve witnessed countless debates about privacy mechanisms in JavaScript. The introduction of JavaScript private elements (MDN) marked a fundamental shift in how we think about encapsulation in the language. While TypeScript’s private modifiers have served us well as training wheels, it’s time to acknowledge that JavaScript’s native private elements represent the superior approach for building robust, secure applications at scale.

The Fundamental Difference: Runtime vs Compile-Time

TypeScript’s private modifiers operate purely at compile-time. They’re suggestions, not enforcements. Once your TypeScript compiles to JavaScript, those private modifiers vanish like morning mist. Any developer with access to your runtime code can reach into your objects and manipulate supposedly “private” members.

// TypeScript code
class BankAccount {
  private balance: number = 1000;

  withdraw(amount: number) {
    if (amount <= this.balance) {
      this.balance -= amount;
    }
  }
}

const account = new BankAccount();
// This would fail TypeScript compilation
// account.balance = 1000000; 

// But in JavaScript runtime...
(account as any).balance = 1000000; // Works perfectly fine
console.log((account as any).balance); // 1000000
Enter fullscreen mode Exit fullscreen mode

JavaScript private elements, denoted by the # prefix, enforce privacy at the runtime level. They’re not just hidden—they’re genuinely inaccessible outside the class definition.

class BankAccount {
  #balance = 1000;

  withdraw(amount: number) {
    if (amount <= this.#balance) {
      this.#balance -= amount;
    }
  }
}

const account = new BankAccount();
// These all throw errors at runtime
account.#balance; // SyntaxError
account['#balance']; // undefined
Object.getOwnPropertyNames(account); // [] - doesn't show #balance
Enter fullscreen mode Exit fullscreen mode

This isn’t just academic pedantry. In production systems handling sensitive data, the difference between compile-time suggestions and runtime enforcement can mean the difference between a secure system and a data breach.

True Encapsulation in Distributed Systems

When building microservices or distributed systems, you often expose objects across module boundaries. With TypeScript’s private modifiers, you’re trusting every consumer of your module to respect your privacy boundaries. This trust-based model breaks down in several scenarios:

  1. Third-party integrations: External developers might not use TypeScript or might disable strict type checking
  2. Dynamic property access: Reflection-based frameworks and serialization libraries often bypass TypeScript’s type system
  3. Legacy code integration: When interfacing with existing JavaScript codebases

JavaScript private elements provide hard boundaries that survive these scenarios:

class SecureAPIClient {
  #apiKey;
  #rateLimiter;

  constructor(apiKey) {
    this.#apiKey = apiKey;
    this.#rateLimiter = new RateLimiter();
  }

  async request(endpoint) {
    if (!this.#rateLimiter.canMakeRequest()) {
      throw new Error('Rate limit exceeded');
    }

    return fetch(endpoint, {
      headers: {
        'Authorization': `Bearer ${this.#apiKey}`
      }
    });
  }
}

// No amount of runtime manipulation can extract #apiKey
const client = new SecureAPIClient('secret-key');
JSON.stringify(client); // {}
Object.keys(client); // []
Object.getOwnPropertyDescriptors(client); // {}
Enter fullscreen mode Exit fullscreen mode

Performance Implications: The Surprising Truth

Conventional wisdom suggests that TypeScript’s compile-time approach would be more performant since there’s no runtime overhead. However, modern JavaScript engines have optimized private fields to be remarkably efficient. V8, SpiderMonkey, and JavaScriptCore all implement private fields using efficient internal slots that often outperform property lookups on regular objects.

Consider this performance comparison:

// Regular property access (what TypeScript compiles to)
class RegularClass {
  constructor() {
    this._private = 0;
  }

  increment() {
    return ++this._private;
  }
}

// Private field access
class PrivateClass {
  #private = 0;

  increment() {
    return ++this.#private;
  }
}

// Benchmark results on V8:
// RegularClass: ~145M ops/sec
// PrivateClass: ~148M ops/sec
Enter fullscreen mode Exit fullscreen mode

The performance difference is negligible, and in some cases, private fields actually perform better due to guaranteed non-configurability and internal optimization paths.

Debugging and Development Experience

Critics often point to debugging challenges with private fields. It’s true that you can’t inspect private fields as easily in debuggers. However, this limitation has driven better debugging practices:

class DebuggableService {
  #state = { connections: 0, errors: 0 };

  // Explicit debug interface
  getDebugInfo() {
    if (process.env.NODE_ENV === 'development') {
      return {
        connections: this.#state.connections,
        errors: this.#state.errors,
        timestamp: Date.now()
      };
    }
    return null;
  }

  // Chrome DevTools protocol support
  [Symbol.for('nodejs.util.inspect.custom')]() {
    return `DebuggableService { connections: ${this.#state.connections} }`;
  }
}
Enter fullscreen mode Exit fullscreen mode

This pattern encourages explicit debugging interfaces rather than relying on implementation details—a practice that leads to more maintainable code.

WeakMap Patterns: The Bridge Too Far

Before private fields, the community developed WeakMap-based patterns for true privacy:

const privateData = new WeakMap();

class OldSchoolPrivate {
  constructor() {
    privateData.set(this, { secret: 'hidden' });
  }

  getSecret() {
    return privateData.get(this).secret;
  }
}
Enter fullscreen mode Exit fullscreen mode

While this achieves runtime privacy, it’s verbose, error-prone, and performs worse than native private fields. JavaScript private elements provide the same security guarantees with cleaner syntax and better performance.

Static Private Members: The Complete Package

JavaScript private elements extend beyond instance fields to include static private members, something TypeScript can only approximate:

class ConfigManager {
  static #instance;
  static #config = new Map();

  static getInstance() {
    if (!this.#instance) {
      this.#instance = new ConfigManager();
    }
    return this.#instance;
  }

  static #validateConfig(config) {
    // Private static method - truly inaccessible
    return config && typeof config === 'object';
  }

  setConfig(key, value) {
    if (ConfigManager.#validateConfig(value)) {
      ConfigManager.#config.set(key, value);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This enables sophisticated patterns like truly private singletons and shared private state across instances without exposing implementation details.

The Ecosystem Evolution

Modern frameworks and libraries are increasingly adopting JavaScript private fields. Vue 3’s reactivity system, MobX 6, and numerous other libraries use private fields for internal state management. This trend indicates the ecosystem’s recognition of their superiority:

// Modern library pattern
class ReactiveStore {
  #subscribers = new Set();
  #state = {};
  #isUpdating = false;

  subscribe(callback) {
    this.#subscribers.add(callback);
    return () => this.#subscribers.delete(callback);
  }

  #notifySubscribers() {
    if (this.#isUpdating) return;
    this.#isUpdating = true;
    this.#subscribers.forEach(cb => cb(this.#state));
    this.#isUpdating = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Addressing the Weaknesses

To maintain objectivity, let’s acknowledge the challenges with JavaScript private elements:

  1. No dynamic access: You cannot use bracket notation or computed property names with private fields
  2. No inheritance access: Subclasses cannot access parent private fields
  3. Tooling lag: Some older tools and transpilers struggle with private field syntax

However, these “limitations” often guide us toward better design patterns:

// Instead of dynamic access, use explicit interfaces
class DataStore {
  #data = {};

  // Bad: Trying to use private fields dynamically
  // get(key) { return this.#[key]; } // Syntax error

  // Good: Explicit interface
  get(key) {
    return this.#data[key];
  }

  set(key, value) {
    this.#data[key] = value;
  }
}

// Instead of inheritance access, use composition
class BaseService {
  #logger;

  constructor() {
    this.#logger = this.createLogger();
  }

  // Protected method pattern
  createLogger() {
    return new Logger();
  }

  logInfo(message) {
    this.#logger.info(message);
  }
}
Enter fullscreen mode Exit fullscreen mode

Migration Strategies

For teams using TypeScript, migrating to JavaScript private elements doesn’t mean abandoning TypeScript. You can use both:

class HybridApproach {
  // TypeScript for type information
  private typedField: string = "typed";

  // JavaScript for runtime privacy
  #secureField = "actually private";

  // Use TypeScript private for internal APIs
  private helperMethod(): void {
    // Implementation
  }

  // Use JavaScript private for security-critical data
  #cryptographicKey = generateKey();
}
Enter fullscreen mode Exit fullscreen mode

Conclusion: Embracing True Privacy

JavaScript private elements represent a maturation of the language. They provide what TypeScript’s private modifiers promised but couldn’t deliver: actual privacy. In an era where security breaches cost millions and data privacy regulations grow stricter, the difference between “please don’t touch” and “cannot touch” becomes critical.

The cloud-native and distributed systems we build today demand real boundaries, not polite suggestions. JavaScript private elements deliver these boundaries with elegant syntax, excellent performance, and growing ecosystem support. While TypeScript’s type system remains invaluable for development-time safety and documentation, when it comes to runtime privacy, JavaScript’s native solution is unequivocally superior.

As we architect the next generation of secure, scalable systems, it’s time to move beyond compile-time theater and embrace runtime reality. JavaScript private elements aren’t just an incremental improvement—they’re a fundamental advancement in how we build secure JavaScript applications. The question isn’t whether to adopt them, but how quickly you can migrate your critical code to use them.

Top comments (1)

Collapse
 
andriy_ovcharov_312ead391 profile image
Andriy Ovcharov

Interesting. Thanks for sharing!