{"slug": "typescript-access-modifiers-in-2026-why-private-fields-beat-and-when-the-is-true", "title": "TypeScript Access Modifiers in 2026: Why `private` Fields Beat `#` and When the Opposite Is True", "summary": "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.", "body_md": "`private`\n\nFields Beat `#`\n\nand When the Opposite Is True\n\nThis article was written with the assistance of AI, under human supervision and review.\n\nMost privacy bugs in TypeScript codebases stem from misunderstanding the two fundamentally incompatible encapsulation models: compile-time `private`\n\nmodifiers and runtime ECMAScript `#`\n\nfields. Teams pick one arbitrarily, ship to production, then discover edge cases where their choice breaks catastrophically.\n\nThe TypeScript `private`\n\nkeyword 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.\n\nECMAScript `#`\n\nfields 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.\n\nThe choice between these patterns determines whether your encapsulation survives production. This distinction is critical.\n\n`private`\n\nmodifiers disappear after compilation, leaving plain JavaScript properties accessible at runtime. ECMAScript `#`\n\nfields enforce hard privacy through WeakMap storage that survives transpilation.`private`\n\nfor type safety in controlled TypeScript-only codebases where compile-time checks suffice. Use `#`\n\nwhen shipping libraries, working with dynamic imports, or protecting sensitive data from runtime inspection.`private`\n\nto `#`\n\nchanges your public API surface and breaks reflection-based tooling. Codebases need explicit conventions to prevent mixing both inconsistently.`private`\n\nbecause 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`\n\nModifier: Compile-Time Only\nTypeScript's `private`\n\nmodifier 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`\n\na documentation tool more than a security feature.\n\n```\nclass UserSession {\n  private token: string;\n  private expiresAt: number;\n\n  constructor(token: string, ttl: number) {\n    this.token = token;\n    this.expiresAt = Date.now() + ttl;\n  }\n\n  isValid(): boolean {\n    return Date.now() < this.expiresAt;\n  }\n}\n\nconst session = new UserSession(\"abc123\", 3600000);\n// TypeScript error: Property 'token' is private\n// console.log(session.token);\n```\n\nThe emitted JavaScript looks like this:\n\n```\nclass UserSession {\n  constructor(token, ttl) {\n    this.token = token;\n    this.expiresAt = Date.now() + ttl;\n  }\n\n  isValid() {\n    return Date.now() < this.expiresAt;\n  }\n}\n\nconst session = new UserSession(\"abc123\", 3600000);\n// Works perfectly at runtime\nconsole.log(session.token); // \"abc123\"\n```\n\nThe `private`\n\nkeyword vanished. Any JavaScript consumer can read or mutate the field directly. This matters in three scenarios: publishing libraries to npm, loading third-party modules dynamically, or working with reflection-based frameworks like serializers or ORMs.\n\nThe advantage of `private`\n\nmodifiers 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.\n\nThe 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.\n\nThis 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`\n\nbecomes a suggestion.\n\n`#`\n\n): Runtime-Enforced Hard Privacy\nECMAScript private fields use the `#`\n\nprefix 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 `#`\n\nsyntax when targeting ES2022 or later.\n\n```\nclass SecureWallet {\n  #balance: number;\n  #encryptionKey: string;\n\n  constructor(initialBalance: number, key: string) {\n    this.#balance = initialBalance;\n    this.#encryptionKey = key;\n  }\n\n  getBalance(): number {\n    return this.#balance;\n  }\n\n  deposit(amount: number): void {\n    if (amount <= 0) throw new Error(\"Invalid amount\");\n    this.#balance += amount;\n  }\n}\n\nconst wallet = new SecureWallet(1000, \"secret-key\");\nconsole.log(wallet.getBalance()); // 1000\n\n// Runtime TypeError: cannot access private field\n// console.log(wallet.#balance);\n```\n\nThe emitted JavaScript retains the `#`\n\nsyntax when targeting modern environments:\n\n```\nclass SecureWallet {\n  #balance;\n  #encryptionKey;\n\n  constructor(initialBalance, key) {\n    this.#balance = initialBalance;\n    this.#encryptionKey = key;\n  }\n\n  getBalance() {\n    return this.#balance;\n  }\n\n  deposit(amount) {\n    if (amount <= 0) throw new Error(\"Invalid amount\");\n    this.#balance += amount;\n  }\n}\n```\n\nThe browser or Node.js runtime enforces encapsulation. Attempting `wallet.#balance`\n\nthrows a syntax error in strict mode. Even `Object.keys(wallet)`\n\nreturns an empty array because private fields exist outside the property enumeration system.\n\nThe cost of `#`\n\nfields 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.\n\nThe other tradeoff is developer experience. Autocomplete cannot suggest `#`\n\nfields from outside the class. Debugging tools sometimes hide private fields in object inspectors. Serialization libraries like `JSON.stringify`\n\nskip private fields silently, which surprises teams expecting complete object graphs.\n\nPrivate 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.\n\nThe decision between `private`\n\nmodifiers and `#`\n\nfields 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.\n\nUse TypeScript `private`\n\nwhen:\n\n`#`\n\nfield polyfills add unacceptable bundle weight.Use ECMAScript `#`\n\nfields when:\n\nThe 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 `#`\n\nfields disappear from property lists. The workaround involves explicit getter methods or metadata decorators, adding ceremony that teams resist.\n\nAnother edge case appears in testing. TypeScript `private`\n\nfields allow test files in the same project to access internals through type assertions. ECMAScript `#`\n\nfields require extracting testable logic into separate methods or using dependency injection patterns. Teams accustomed to testing private implementation details find this friction jarring.\n\nThe 2026 landscape shows growing adoption of `#`\n\nfields in security-critical libraries and persistence in `private`\n\nmodifiers 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.\n\nA production codebase often needs both patterns serving different purposes. The key is consistency within logical boundaries: use `private`\n\nfor internal implementation details and `#`\n\nfor security-critical fields.\n\n```\nclass APIClient {\n  // Public configuration\n  public readonly baseURL: string;\n\n  // Compile-time private implementation detail\n  private requestCache: Map<string, Promise<unknown>>;\n\n  // Runtime private security credential\n  #authToken: string;\n\n  constructor(baseURL: string, token: string) {\n    this.baseURL = baseURL;\n    this.requestCache = new Map();\n    this.#authToken = token;\n  }\n\n  async fetch<T>(endpoint: string): Promise<T> {\n    const url = `${this.baseURL}${endpoint}`;\n\n    // Cache lookup using private field\n    const cached = this.requestCache.get(url);\n    if (cached) return cached as Promise<T>;\n\n    // Authentication using # field\n    const promise = this.makeAuthenticatedRequest<T>(url);\n    this.requestCache.set(url, promise);\n    return promise;\n  }\n\n  private async makeAuthenticatedRequest<T>(url: string): Promise<T> {\n    const response = await fetch(url, {\n      headers: {\n        Authorization: `Bearer ${this.#authToken}`,\n      },\n    });\n\n    if (!response.ok) {\n      throw new Error(`HTTP ${response.status}`);\n    }\n\n    return response.json();\n  }\n\n  // Safe public method to rotate credentials\n  updateToken(newToken: string): void {\n    this.#authToken = newToken;\n    this.requestCache.clear();\n  }\n}\n```\n\nThe `requestCache`\n\nuses `private`\n\nbecause testing and debugging tools need visibility. Serialization libraries can enumerate it if needed. The `#authToken`\n\nuses hard privacy because exposing it at runtime creates a security vulnerability.\n\nA 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.\n\nAnother practical example: state machines with private transition logic and hard-private state:\n\n```\nclass OrderStateMachine {\n  // Hard-private current state\n  #currentState: \"pending\" | \"confirmed\" | \"shipped\" | \"delivered\";\n\n  // Private transition validator\n  private validTransitions: Map<string, Set<string>>;\n\n  constructor(initialState: \"pending\" | \"confirmed\" = \"pending\") {\n    this.#currentState = initialState;\n    this.validTransitions = new Map([\n      [\"pending\", new Set([\"confirmed\"])],\n      [\"confirmed\", new Set([\"shipped\"])],\n      [\"shipped\", new Set([\"delivered\"])],\n    ]);\n  }\n\n  getState(): string {\n    return this.#currentState;\n  }\n\n  transition(toState: \"pending\" | \"confirmed\" | \"shipped\" | \"delivered\"): void {\n    const allowed = this.validTransitions.get(this.#currentState);\n\n    if (!allowed?.has(toState)) {\n      throw new Error(\n        `Invalid transition: ${this.#currentState} -> ${toState}`\n      );\n    }\n\n    this.#currentState = toState;\n  }\n\n  private validateTransition(from: string, to: string): boolean {\n    return this.validTransitions.get(from)?.has(to) ?? false;\n  }\n}\n```\n\nThe state machine exposes `getState()`\n\npublicly but hides the raw `#currentState`\n\nto prevent external mutation. The `validTransitions`\n\nmap uses `private`\n\nbecause test suites may need to verify edge cases by inspecting the ruleset.\n\nThis pattern scales. A codebase with 50 classes might use `#`\n\nfields in 10 authentication-related classes and `private`\n\nmodifiers everywhere else. The convention documents intent: seeing `#`\n\nsignals a security boundary.\n\nMigrating from `private`\n\nto `#`\n\nfields is a breaking change at the API surface. Tools relying on property enumeration will break. The migration requires coordination across teams and gradual rollout.\n\nThe migration path for a library:\n\n`#`\n\nsyntax 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 `#`\n\nis required versus optional.\n\nA workable convention:\n\n`#`\n\nfor authentication tokens, encryption keys, database credentials, or personally identifiable information.`private`\n\nfor caching layers, configuration objects, internal state machines, or computed properties.`private`\n\nfields.The ESLint rule for this might look like:\n\n```\n// Example custom rule (pseudocode)\nif (fieldName.includes(\"token\") || fieldName.includes(\"key\")) {\n  if (modifier === \"private\" && !syntax.includes(\"#\")) {\n    report(\"Security-critical field must use # syntax\");\n  }\n}\n```\n\nTeams working across TypeScript and JavaScript need different conventions. A monorepo with TypeScript services and legacy JavaScript modules cannot use `#`\n\nfields universally without transpilation overhead. The boundary becomes repository-level: new TypeScript code uses `#`\n\nfor sensitive fields, legacy code stays unchanged until rewrite.\n\nThe pattern [correlation IDs for AI agents](https://jsmanifest.com/correlation-ids-ai-agents) demonstrates this hybrid approach in distributed systems where some services enforce hard privacy and others rely on type-level contracts.\n\nMigration 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.\n\n`private`\n\nmodifiers and `#`\n\nfields in the same class?\nYes, TypeScript 5.7+ supports both syntaxes simultaneously. Use `private`\n\nfor implementation details that might need testing or reflection access, and `#`\n\nfor security-critical fields that must resist runtime inspection. The compiler treats them as distinct visibility mechanisms with compatible semantics.\n\n`#`\n\nfields work in older JavaScript environments like IE11?\nNot natively. When targeting ES5 or ES2015, TypeScript transpiles `#`\n\nfields into WeakMap-based polyfills that add bundle weight and runtime overhead. For legacy browser support, stick with `private`\n\nmodifiers and rely on build-time visibility checks instead of runtime enforcement.\n\n`#`\n\nfields during JSON serialization?\nPrivate fields are invisible to `JSON.stringify`\n\nand 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`\n\nmethod that exposes controlled representations of internal data.\n\n`#`\n\nfields?\nNo. ECMAScript private fields are scoped strictly to the defining class. Subclasses cannot read or write parent `#`\n\nfields even through protected or public methods. This differs from `private`\n\nmodifiers where the TypeScript compiler allows subclass access in some cases through explicit type assertions.\n\n`private`\n\nto `#`\n\nfields?\nOnly 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`\n\nmodifiers provide sufficient encapsulation without the migration cost. Focus migration efforts on libraries, public APIs, or security-critical modules first.\n\nThe decision between TypeScript `private`\n\nand ECMAScript `#`\n\nfields 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.\n\nUse `private`\n\nwhen you control the entire dependency graph and value developer tooling over runtime enforcement. Use `#`\n\nwhen shipping to untrusted environments or protecting sensitive data that must resist reflection. Most codebases need both, applied thoughtfully to different field categories.\n\nThe 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.\n\nTeams building internal tools can lean on `private`\n\nmodifiers and benefit from mature tooling ecosystems. Teams publishing libraries or working in security-sensitive domains need `#`\n\nfields to enforce contracts at runtime. The 2026 TypeScript landscape supports both patterns equally well. The choice reflects your threat model and consumer trust assumptions.\n\nThat 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](https://jsmanifest.com/create-a-modern-typescript-javascript-library-for-2023) and [Biome versus Oxlint](https://jsmanifest.com/biome-oxlint-comparison-2026) for 2026 best practices.", "url": "https://wpnews.pro/news/typescript-access-modifiers-in-2026-why-private-fields-beat-and-when-the-is-true", "canonical_source": "https://dev.to/jsmanifest/typescript-access-modifiers-in-2026-why-private-fields-beat-and-when-the-opposite-is-true-1kai", "published_at": "2026-09-03 20:05:16+00:00", "updated_at": "2026-09-03 20:25:22.302606+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["TypeScript", "ECMAScript"], "alternates": {"html": "https://wpnews.pro/news/typescript-access-modifiers-in-2026-why-private-fields-beat-and-when-the-is-true", "markdown": "https://wpnews.pro/news/typescript-access-modifiers-in-2026-why-private-fields-beat-and-when-the-is-true.md", "text": "https://wpnews.pro/news/typescript-access-modifiers-in-2026-why-private-fields-beat-and-when-the-is-true.txt", "jsonld": "https://wpnews.pro/news/typescript-access-modifiers-in-2026-why-private-fields-beat-and-when-the-is-true.jsonld"}}