Home / Articles / TypeScript Private Fields vs # Syntax: Compile-Time or Runtime Privacy

This article is published in English.

TypeScript Private Fields vs # Syntax: Compile-Time or Runtime Privacy

Compares TypeScript's compile-time `private` modifier with ECMAScript's runtime-enforced `#` fields to help you choose the right encapsulation strategy for 2026 codebases.

2729 words

Most privacy-related defects in TypeScript projects trace back to confusing two encapsulation models that don't actually work the same way: the compiler-only private keyword and the runtime-enforced # field syntax from ECMAScript. Teams often settle on one approach without much thought, ship it, and later run into situations where that choice fails in unexpected ways.

TypeScript's private keyword provides no protection once your code runs. The compiler checks visibility while you write and build the project, but the JavaScript it outputs turns every "private" member into an ordinary public property. Anything that consumes the compiled output can reach past the access modifier entirely.

ECMAScript # fields take a different approach, delivering genuine privacy. The runtime itself enforces the boundary by keeping field data in an internal WeakMap, so external code has no way to reach it. That guarantee protects you from accidental misuse and keeps sensitive values safe even in environments you don't fully trust.

Picking between these two approaches decides whether your encapsulation actually holds up once code reaches production, which is why getting this right matters so much.

Key Takeaways

  • TypeScript's private modifier is erased during compilation, leaving behind plain, freely accessible JavaScript properties. ECMAScript # fields, by contrast, use WeakMap-backed storage that keeps privacy intact even after transpilation.
  • Reach for private when you need type-level safety inside a codebase where TypeScript is the only consumer and compile-time checks are enough. Reach for # when you're publishing a library, dealing with dynamic imports, or shielding sensitive values from runtime inspection.
  • These two mechanisms aren't drop-in replacements for each other. Switching a class from private to # changes what the public API looks like and can break tools that rely on reflection. Without a documented convention, codebases end up mixing both approaches inconsistently.
  • Many teams lean on private simply because it resembles patterns from languages like Java, then get caught off guard when JavaScript-only consumers ignore the contract entirely. The resulting bugs tend to be quiet but costly to track down.
  • As of 2026, TypeScript 5.7 and later versions support both mechanisms with complete type inference. Choosing between them really comes down to trust: do you control every piece of code that touches your class, or could it end up running somewhere hostile?

Understanding TypeScript's private Modifier: Compile-Time Only

The private keyword in TypeScript is purely a type-system construct. It blocks unauthorized access while you're developing, but once compiled, the resulting JavaScript contains regular, unguarded properties. In practice, private functions more as a documentation aid than as an actual security boundary.

Once a class with a private field is compiled down to plain JavaScript, the modifier disappears completely, and the field turns into an ordinary property that any consumer can read or overwrite directly. This becomes a real concern in three situations: shipping packages to npm, dynamically importing third-party modules, or integrating with reflection-driven tools such as serializers and ORMs.

Simplicity is the main selling point of private. Developers coming from languages like Java or C# pick it up immediately. Editors hide private members from autocomplete suggestions, refactoring tools respect the intended visibility, and the compiler flags accidental exposure during development and code review.

Trouble starts when assumptions about the runtime turn out to be wrong. Imagine a team building an internal dashboard under the assumption that every consumer of their code runs TypeScript. Months later, a service written in Python loads the compiled JavaScript bundle and starts modifying session tokens directly. The supposed privacy boundary was never real outside the compiler, so it offered no resistance at all.

This model works fine as long as you control the whole dependency chain and TypeScript is enforced end to end. As soon as your code crosses a language boundary or gets published somewhere public, private stops being a guarantee and becomes little more than a suggestion.

ECMAScript Private Fields (#): Runtime-Enforced Hard Privacy

Fields prefixed with # are a native JavaScript feature that creates properties genuinely unreachable from outside the class. The engine keeps them in an internal WeakMap, hidden from reflection and from any external code trying to access them. When you target ES2022 or newer, TypeScript keeps the # syntax intact in its output.

When compiling for modern targets, the emitted JavaScript preserves the # syntax as-is rather than converting it into a normal property. Encapsulation here is guaranteed by the runtime itself: trying to reach a field like wallet.#balance from outside the class throws a syntax error under strict mode. Even calling Object.keys(wallet) returns an empty array, since private fields sit outside the normal property enumeration mechanism entirely.

The tradeoff with # fields is compatibility. Targeting older environments such as ES5 or ES2015 forces TypeScript to emit WeakMap-based polyfills, which add extra bundle weight and per-access overhead — a real concern for libraries aimed at performance-sensitive browser environments.

There's also a developer-experience cost. Editors can't offer autocomplete for # fields from outside their class, and some debugging tools hide them from object inspectors by default. Serialization utilities such as JSON.stringify silently skip private fields as well, which can surprise developers expecting a complete snapshot of an object's state.

Private fields make the most sense in three cases: safeguarding cryptographic keys or access tokens, stopping API consumers from tampering with internal invariants, and running code in environments where you can't fully trust what else is executing alongside it. In these scenarios, the strong runtime guarantee is worth more than the convenience you give up.

Side-by-Side Comparison: When Each Approach Wins

Choosing between private and # ultimately depends on your trust boundaries and tooling needs — neither one is the correct default in every situation. This means teams should agree on explicit rules rather than just going with whatever feels most familiar.

Reach for TypeScript's private when:

  • You're building an internal application where every consumer is TypeScript code under strict compiler settings.
  • You depend on ORMs, serializers, or other reflection-based tools that need to enumerate properties to build database schemas or API payloads.
  • You're compiling for older runtimes like ES5, where # field polyfills would add too much bundle size.
  • Developer experience and autocomplete matter more than runtime-level security for that particular class.

Reach for ECMAScript # fields when:

  • You're publishing a package to npm and can't guarantee every consumer will respect TypeScript-only conventions.
  • You're storing sensitive values such as auth tokens, encryption keys, or payment information that shouldn't be inspectable.
  • You're building a plugin system where untrusted third-party code shares the same runtime as your own.
  • You're designing a framework or SDK where the API contract needs to be enforced structurally, not just documented.

Conflicts arise when a design needs both reflection support and true runtime privacy at once. A typical case: an ORM tries to enumerate every field to build a database mapping, but # fields simply don't show up in that enumeration. Getting around this usually means adding explicit getter methods or metadata decorators, which adds complexity that many teams would rather avoid.

Testing introduces another wrinkle. With TypeScript's private, test files in the same project can still reach internal state through type assertions. With # fields, you generally have to extract testable behavior into separate methods or lean on dependency injection instead. Teams used to poking at private internals during testing often find this extra friction annoying.

Looking at how things stand in 2026, # fields are increasingly common in security-sensitive libraries, while private modifiers remain the norm for internal application code. TypeScript 5.7 treats both as fully supported first-class features, complete with inference and error checking, so the decision is really about architecture rather than compiler capability.

Real-World Code: Implementing Both Patterns

It's common for a single production codebase to use both patterns for different purposes at once. The important thing is staying consistent within a given boundary: use private for ordinary implementation details, and reserve # for fields tied to security.

Consider a class that keeps a requestCache field marked as private alongside an #authToken field marked as hard-private. The requestCache stays as private because testing and debugging tools benefit from being able to see it, and serialization tooling can pick it up if that's ever useful. Meanwhile, #authToken gets hard privacy since exposing it at runtime would create an actual security hole.

Mixing the two makes sense whenever fields carry different levels of risk. Things like configuration values and caches are internal details where some flexibility is fine. Credentials and cryptographic keys, on the other hand, need the stronger runtime guarantee.

Another practical illustration is a state machine that keeps its transition rules under private but hides its actual state behind #. The state machine exposes state only through a getState() method while keeping the underlying #currentState field out of reach, preventing outside code from mutating it directly. The validTransitions map stays as a private field because test suites may still need to inspect the ruleset to check edge cases.

This convention scales reasonably well. A codebase with, say, 50 classes might use # fields in around 10 authentication-related classes while relying on private everywhere else. Seeing # in the code then acts as a clear signal that you've crossed into a security-sensitive boundary.

Migration Strategies and Team Conventions

Moving a class from private to # is a breaking change as far as its public surface is concerned. Any tooling that depends on enumerating properties will stop working correctly, so this kind of migration needs to be planned deliberately and rolled out gradually, with coordination across whichever teams depend on the affected code.

Migrating a class from private to # fields typically follows a predictable sequence:

  1. Review which fields actually require runtime-enforced privacy versus fields that only need compiler-level visibility checks.
  2. Cut a major version release that documents the change to the public API surface.
  3. Convert the security-critical fields to # syntax inside a dedicated feature branch.
  4. Rework internal tests so they no longer depend on reaching into fields directly.
  5. Confirm that serialization libraries and ORMs still behave correctly against the updated field layout.
  6. Publish the release with detailed notes explaining exactly what broke and why.

Internal codebases have an easier path here. Since there's no external consumer to worry about, teams can roll out the change incrementally without tracking semantic versioning. The real difficulty becomes coordination: developers need a shared understanding of when # is mandatory and when private remains acceptable.

A practical convention many teams adopt looks like this:

  • Reserve # for authentication tokens, encryption keys, database credentials, and personally identifiable information.
  • Keep private for caching layers, configuration state, internal state machines, and derived or computed values.
  • Write the reasoning into your code review checklist and onboarding docs so new hires absorb the rule quickly.
  • Add lint rules that catch risky patterns, such as a password field declared with private instead of #.

Organizations that maintain both TypeScript and legacy JavaScript in the same repository need a slightly different rule set. In a monorepo combining TypeScript services with older JavaScript modules, applying # fields everywhere isn't practical because of the transpilation cost it introduces for code that doesn't need it. In that setup, the dividing line tends to sit at the repository or package level: newly written TypeScript modules adopt # for sensitive data, while legacy JavaScript stays as-is until it's eventually rewritten.

A hybrid strategy along these lines shows up in distributed systems that mix hard runtime privacy in some services with type-level contracts in others, where certain components enforce strict encapsulation and others rely purely on compiler checks.

Most migration problems trace back to treating the switch as a purely mechanical find-and-replace. Swapping private for # without first auditing every consumer of that field invites silent breakage. Consider a logging utility built around reflection that walks an object's properties to produce debug output — once those properties become # fields, they vanish from that enumeration and the logger silently loses visibility into the object's state. The proper fix is to expose the needed data through explicit getter methods or a structured logging interface, rather than relying on reflection to surface private internals.

Frequently Asked Questions

Can I mix private modifiers and # fields in the same class?

Yes. TypeScript 5.7 and later allow both mechanisms to coexist within a single class definition. A common pattern is applying private to implementation details that tests or debugging tools may still need to reach, while reserving # for the handful of fields that must resist any form of runtime inspection. The compiler handles them as two separate but compatible visibility systems.

Do # fields work in older JavaScript environments like IE11?

Not directly. When your build target is ES5 or ES2015, the TypeScript compiler falls back to emitting WeakMap-based polyfills to simulate # field behavior, which adds extra code size and a runtime performance cost. If you still need to support legacy browsers, it's better to stick with private modifiers and accept compile-time-only enforcement rather than pay for the polyfill overhead.

What happens to # fields during JSON serialization?

JSON.stringify and comparable serialization utilities simply cannot see # fields, so any object containing them will serialize without those properties at all. If you need part of that private state represented in the output, you have to expose it deliberately — either through a getter method or by defining a custom toJSON implementation that controls exactly what gets included.

Can subclasses access parent class # fields?

No. ECMAScript private fields belong exclusively to the class where they're declared, and that boundary is absolute — a subclass has no way to read or modify a parent's # fields, even indirectly through protected or public methods. This is a meaningful difference from private modifiers, where the TypeScript compiler sometimes permits subclass access via explicit type assertions.

Should I migrate existing codebases from private to # fields?

Only when there's a concrete need — a real security exposure or a genuine requirement for runtime-enforced privacy. Since the migration counts as a breaking change affecting tooling, serialization behavior, and test design, it's not something to do casually. For the majority of internal applications, private modifiers already provide adequate encapsulation, and the migration cost isn't worth paying. Prioritize the move for shared libraries, public-facing APIs, or modules that handle sensitive data first.

Choosing the Right Privacy Model for Your Codebase in 2026

Deciding between TypeScript's private and ECMAScript's # fields isn't a matter of arbitrary preference. The choice shapes whether your encapsulation guarantee survives past the compiler, affects how external code can interact with your classes, and communicates architectural intent to whoever maintains the code later.

Lean on private when you control the full dependency graph and prioritize developer tooling over strict runtime guarantees. Reach for # when your code runs in untrusted environments or handles data that must never be exposed through reflection. Most real-world codebases end up needing a mix of both, applied deliberately depending on what each field represents.

Getting this decision wrong tends to surface in production: invariants get broken by accidental mutation, credentials leak out through logging infrastructure, or test suites end up unable to verify internal state at all. All of these are avoidable with clear conventions and documented architectural rules.

Teams building internal tooling can generally rely on private modifiers and take advantage of the mature tooling built around them. Teams shipping public libraries or operating in security-sensitive domains need the runtime guarantees that # fields provide. Both patterns are equally well supported across the current TypeScript ecosystem — the right choice depends on your threat model and how much trust you place in your API's consumers.