cd /news/developer-tools/typescript-access-modifiers-in-2026-… · home topics developer-tools article
[ARTICLE · art-120792] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

TypeScript Access Modifiers in 2026: Why `private` Fields Beat `#` and When the Opposite Is True

A developer explains the critical differences between TypeScript's compile-time `private` modifiers and ECMAScript `#` fields, noting that `private` offers no runtime protection while `#` fields enforce hard privacy via WeakMap storage. The post advises using `private` for controlled TypeScript-only codebases and `#` for libraries or sensitive data, highlighting the risks of mixing both patterns.

read11 min views2 publishedSep 3, 2026

private

Fields Beat #

and When the Opposite Is True

This article was written with the assistance of AI, under human supervision and review.

Most privacy bugs in TypeScript codebases stem from misunderstanding the two fundamentally incompatible encapsulation models: compile-time private

modifiers and runtime ECMAScript #

fields. Teams pick one arbitrarily, ship to production, then discover edge cases where their choice breaks catastrophically.

The TypeScript private

keyword offers zero runtime protection. The compiler enforces visibility during development, but the emitted JavaScript exposes every field as a plain public property. Any consumer importing the transpiled code bypasses the entire privacy contract.

ECMAScript #

fields solve this with hard privacy. The JavaScript runtime enforces encapsulation using WeakMap storage, making truly inaccessible fields that no external code can reach. This prevents accidental breakage and secures sensitive state in untrusted environments.

The choice between these patterns determines whether your encapsulation survives production. This distinction is critical.

private

modifiers disappear after compilation, leaving plain JavaScript properties accessible at runtime. ECMAScript #

fields enforce hard privacy through WeakMap storage that survives transpilation.private

for type safety in controlled TypeScript-only codebases where compile-time checks suffice. Use #

when shipping libraries, working with dynamic imports, or protecting sensitive data from runtime inspection.private

to #

changes your public API surface and breaks reflection-based tooling. Codebases need explicit conventions to prevent mixing both inconsistently.private

because it feels familiar from other languages, then encounter silent failures when JavaScript consumers bypass the contract. The failure mode here is subtle but expensive.private

Modifier: Compile-Time Only TypeScript's private

modifier exists solely in the type system. The compiler prevents access during development, but the resulting JavaScript contains ordinary properties with no protection mechanism. This makes private

a documentation tool more than a security feature.

class UserSession {
  private token: string;
  private expiresAt: number;

  constructor(token: string, ttl: number) {
    this.token = token;
    this.expiresAt = Date.now() + ttl;
  }

  isValid(): boolean {
    return Date.now() < this.expiresAt;
  }
}

const session = new UserSession("abc123", 3600000);
// TypeScript error: Property 'token' is private
// console.log(session.token);

The emitted JavaScript looks like this:

class UserSession {
  constructor(token, ttl) {
    this.token = token;
    this.expiresAt = Date.now() + ttl;
  }

  isValid() {
    return Date.now() < this.expiresAt;
  }
}

const session = new UserSession("abc123", 3600000);
// Works perfectly at runtime
console.log(session.token); // "abc123"

The private

keyword vanished. Any JavaScript consumer can read or mutate the field directly. This matters in three scenarios: publishing libraries to npm, third-party modules dynamically, or working with reflection-based frameworks like serializers or ORMs.

The advantage of private

modifiers is simplicity. Developers familiar with Java or C# adopt the pattern instantly. IntelliSense hides private members in autocomplete. Refactoring tools understand the visibility contract. The TypeScript compiler catches accidental leaks during code review.

The failure mode appears when assumptions about the runtime environment break. A team building an internal dashboard assumes all consumers use TypeScript. Six months later, a Python service imports the transpiled JavaScript bundle and mutates session tokens directly. The privacy contract collapsed because it never existed outside the compiler.

This pattern works when you control the entire dependency graph and enforce TypeScript everywhere. The moment you cross language boundaries or publish to public registries, private

becomes a suggestion.

#

): Runtime-Enforced Hard Privacy ECMAScript private fields use the #

prefix to create truly inaccessible class properties. The JavaScript runtime stores these in an internal WeakMap, making them invisible to reflection and external code. TypeScript preserves the #

syntax when targeting ES2022 or later.

class SecureWallet {
  #balance: number;
  #encryptionKey: string;

  constructor(initialBalance: number, key: string) {
    this.#balance = initialBalance;
    this.#encryptionKey = key;
  }

  getBalance(): number {
    return this.#balance;
  }

  deposit(amount: number): void {
    if (amount <= 0) throw new Error("Invalid amount");
    this.#balance += amount;
  }
}

const wallet = new SecureWallet(1000, "secret-key");
console.log(wallet.getBalance()); // 1000

// Runtime TypeError: cannot access private field
// console.log(wallet.#balance);

The emitted JavaScript retains the #

syntax when targeting modern environments:

class SecureWallet {
  #balance;
  #encryptionKey;

  constructor(initialBalance, key) {
    this.#balance = initialBalance;
    this.#encryptionKey = key;
  }

  getBalance() {
    return this.#balance;
  }

  deposit(amount) {
    if (amount <= 0) throw new Error("Invalid amount");
    this.#balance += amount;
  }
}

The browser or Node.js runtime enforces encapsulation. Attempting wallet.#balance

throws a syntax error in strict mode. Even Object.keys(wallet)

returns an empty array because private fields exist outside the property enumeration system.

The cost of #

fields is compatibility. Older transpilation targets like ES5 or ES2015 require polyfills that bloat bundle size. TypeScript generates WeakMap-based shims when targeting legacy environments, adding overhead for every private field access. This matters for libraries shipping to browsers with tight performance budgets.

The other tradeoff is developer experience. Autocomplete cannot suggest #

fields from outside the class. Debugging tools sometimes hide private fields in object inspectors. Serialization libraries like JSON.stringify

skip private fields silently, which surprises teams expecting complete object graphs.

Private fields excel in three scenarios: protecting cryptographic keys or tokens, preventing API consumers from breaking internal invariants, and shipping code to untrusted environments where reflection-based attacks matter. The runtime guarantees trump convenience in these cases.

The decision between private

modifiers and #

fields comes down to trust boundaries and tooling requirements. Neither pattern dominates universally. The implication here is that teams need explicit conventions rather than defaulting to familiarity.

Use TypeScript private

when:

#

field polyfills add unacceptable bundle weight.Use ECMAScript #

fields when:

The patterns conflict when you need both reflection and runtime privacy. A common failure case: an ORM expects to enumerate all fields for database mapping, but #

fields disappear from property lists. The workaround involves explicit getter methods or metadata decorators, adding ceremony that teams resist.

Another edge case appears in testing. TypeScript private

fields allow test files in the same project to access internals through type assertions. ECMAScript #

fields require extracting testable logic into separate methods or using dependency injection patterns. Teams accustomed to testing private implementation details find this friction jarring.

The 2026 landscape shows growing adoption of #

fields in security-critical libraries and persistence in private

modifiers for internal codebases. TypeScript 5.7 treats both as first-class citizens with full inference and error checking. The choice is architectural rather than technical.

A production codebase often needs both patterns serving different purposes. The key is consistency within logical boundaries: use private

for internal implementation details and #

for security-critical fields.

class APIClient {
  // Public configuration
  public readonly baseURL: string;

  // Compile-time private implementation detail
  private requestCache: Map<string, Promise<unknown>>;

  // Runtime private security credential
  #authToken: string;

  constructor(baseURL: string, token: string) {
    this.baseURL = baseURL;
    this.requestCache = new Map();
    this.#authToken = token;
  }

  async fetch<T>(endpoint: string): Promise<T> {
    const url = `${this.baseURL}${endpoint}`;

    // Cache lookup using private field
    const cached = this.requestCache.get(url);
    if (cached) return cached as Promise<T>;

    // Authentication using # field
    const promise = this.makeAuthenticatedRequest<T>(url);
    this.requestCache.set(url, promise);
    return promise;
  }

  private async makeAuthenticatedRequest<T>(url: string): Promise<T> {
    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${this.#authToken}`,
      },
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return response.json();
  }

  // Safe public method to rotate credentials
  updateToken(newToken: string): void {
    this.#authToken = newToken;
    this.requestCache.clear();
  }
}

The requestCache

uses private

because testing and debugging tools need visibility. Serialization libraries can enumerate it if needed. The #authToken

uses hard privacy because exposing it at runtime creates a security vulnerability.

A hybrid approach works when fields have different threat models. Configuration and caches are internal details that benefit from flexible access. Credentials and encryption keys demand runtime guarantees.

Another practical example: state machines with private transition logic and hard-private state:

class OrderStateMachine {
  // Hard-private current state
  #currentState: "pending" | "confirmed" | "shipped" | "delivered";

  // Private transition validator
  private validTransitions: Map<string, Set<string>>;

  constructor(initialState: "pending" | "confirmed" = "pending") {
    this.#currentState = initialState;
    this.validTransitions = new Map([
      ["pending", new Set(["confirmed"])],
      ["confirmed", new Set(["shipped"])],
      ["shipped", new Set(["delivered"])],
    ]);
  }

  getState(): string {
    return this.#currentState;
  }

  transition(toState: "pending" | "confirmed" | "shipped" | "delivered"): void {
    const allowed = this.validTransitions.get(this.#currentState);

    if (!allowed?.has(toState)) {
      throw new Error(
        `Invalid transition: ${this.#currentState} -> ${toState}`
      );
    }

    this.#currentState = toState;
  }

  private validateTransition(from: string, to: string): boolean {
    return this.validTransitions.get(from)?.has(to) ?? false;
  }
}

The state machine exposes getState()

publicly but hides the raw #currentState

to prevent external mutation. The validTransitions

map uses private

because test suites may need to verify edge cases by inspecting the ruleset.

This pattern scales. A codebase with 50 classes might use #

fields in 10 authentication-related classes and private

modifiers everywhere else. The convention documents intent: seeing #

signals a security boundary.

Migrating from private

to #

fields is a breaking change at the API surface. Tools relying on property enumeration will break. The migration requires coordination across teams and gradual rollout.

The migration path for a library:

#

syntax in a feature branch.For internal codebases, the process simplifies. Teams can migrate incrementally without versioning concerns. The challenge is coordination: engineers need to know when #

is required versus optional.

A workable convention:

#

for authentication tokens, encryption keys, database credentials, or personally identifiable information.private

for caching layers, configuration objects, internal state machines, or computed properties.private

fields.The ESLint rule for this might look like:

// Example custom rule (pseudocode)
if (fieldName.includes("token") || fieldName.includes("key")) {
  if (modifier === "private" && !syntax.includes("#")) {
    report("Security-critical field must use # syntax");
  }
}

Teams working across TypeScript and JavaScript need different conventions. A monorepo with TypeScript services and legacy JavaScript modules cannot use #

fields universally without transpilation overhead. The boundary becomes repository-level: new TypeScript code uses #

for sensitive fields, legacy code stays unchanged until rewrite.

The pattern correlation IDs for AI agents demonstrates this hybrid approach in distributed systems where some services enforce hard privacy and others rely on type-level contracts.

Migration failures happen when teams treat the change as mechanical. Switching syntax without auditing consumers leads to silent breakage. A reflection-based logger that enumerated properties for debugging suddenly loses visibility into state. The fix requires explicit getter methods or structured logging APIs.

private

modifiers and #

fields in the same class? Yes, TypeScript 5.7+ supports both syntaxes simultaneously. Use private

for implementation details that might need testing or reflection access, and #

for security-critical fields that must resist runtime inspection. The compiler treats them as distinct visibility mechanisms with compatible semantics.

#

fields work in older JavaScript environments like IE11? Not natively. When targeting ES5 or ES2015, TypeScript transpiles #

fields into WeakMap-based polyfills that add bundle weight and runtime overhead. For legacy browser support, stick with private

modifiers and rely on build-time visibility checks instead of runtime enforcement.

#

fields during JSON serialization? Private fields are invisible to JSON.stringify

and similar serializers. The resulting JSON omits those properties entirely. If you need to serialize private state, add explicit getter methods or use a custom toJSON

method that exposes controlled representations of internal data.

#

fields? No. ECMAScript private fields are scoped strictly to the defining class. Subclasses cannot read or write parent #

fields even through protected or public methods. This differs from private

modifiers where the TypeScript compiler allows subclass access in some cases through explicit type assertions.

private

to #

fields? Only if you have identified concrete security risks or runtime privacy requirements. The migration is a breaking change that affects tooling, serialization, and testing patterns. For most internal applications, private

modifiers provide sufficient encapsulation without the migration cost. Focus migration efforts on libraries, public APIs, or security-critical modules first.

The decision between TypeScript private

and ECMAScript #

fields is not a technical coin flip. The pattern you choose determines whether your encapsulation contract survives compilation, impacts how third-party code interacts with your APIs, and signals architectural intent to future maintainers.

Use private

when you control the entire dependency graph and value developer tooling over runtime enforcement. Use #

when shipping to untrusted environments or protecting sensitive data that must resist reflection. Most codebases need both, applied thoughtfully to different field categories.

The cost of choosing wrong shows up in production: accidental mutations breaking invariants, exposed credentials leaking through logging frameworks, or test suites that cannot verify internal state. These failures are preventable with explicit conventions and architectural guidelines.

Teams building internal tools can lean on private

modifiers and benefit from mature tooling ecosystems. Teams publishing libraries or working in security-sensitive domains need #

fields to enforce contracts at runtime. The 2026 TypeScript landscape supports both patterns equally well. The choice reflects your threat model and consumer trust assumptions.

That covers the essential patterns for TypeScript privacy in 2026. Apply these in production and the difference will be immediate. Your APIs will communicate intent clearly, your security boundaries will hold at runtime, and your team will stop debating visibility rules in code review. For deeper patterns on modern TypeScript tooling, see creating a modern TypeScript library and Biome versus Oxlint for 2026 best practices.

── more in #developer-tools 4 stories · sorted by recency
── more on @typescript 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/typescript-access-mo…] indexed:0 read:11min 2026-09-03 ·