TypeScript Access Modifiers in 2026: Why `private` Fields Beat `#` and When the Opposite Is True
The compile-time versus runtime privacy debate ends here. Learn when TypeScript's `private` modifier wins, when ECMAScript `#` fields dominate, and how to choose the right pattern for your production codebase.
TypeScript Access Modifiers in 2026: Why private Fields Beat # and When the Opposite Is True
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.
flowchart LR
A("Developer writes private field") --> B("TypeScript compiler checks access")
B --> C("Emits plain JavaScript property")
C --> D("Runtime allows unrestricted access")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
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.
flowchart LR
A("Developer writes # field") --> B("TypeScript preserves # syntax")
B --> C("Runtime creates WeakMap entry")
C --> D("External access throws TypeError")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The choice between these patterns determines whether your encapsulation survives production. This distinction is critical.
Key Takeaways
- TypeScript
privatemodifiers disappear after compilation, leaving plain JavaScript properties accessible at runtime. ECMAScript#fields enforce hard privacy through WeakMap storage that survives transpilation. - Use
privatefor 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. - The two patterns are not interchangeable. Migrating from
privateto#changes your public API surface and breaks reflection-based tooling. Codebases need explicit conventions to prevent mixing both inconsistently. - Most teams over-rely on
privatebecause it feels familiar from other languages, then encounter silent failures when JavaScript consumers bypass the contract. The failure mode here is subtle but expensive. - In 2026, TypeScript 5.7+ supports both natively with full type inference. The decision comes down to trust boundaries: do you control every consumer, or does your code run in hostile environments?
Understanding TypeScript's 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, loading 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.
flowchart TD
A("Developer writes private field") --> B("TypeScript compiler")
B --> C("Static analysis pass")
C --> D("Emits plain property")
D --> E{Runtime environment}
E --> F("TypeScript consumer: access blocked")
E --> G("JavaScript consumer: access allowed")
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style G stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
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.
ECMAScript Private Fields (#): 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.
flowchart TD
A("Class defines # field") --> B("Runtime allocates WeakMap")
B --> C{Access attempt}
C --> D("Internal method: allowed")
C --> E("External code: TypeError")
D --> F("WeakMap.get succeeds")
E --> G("Access rejected")
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style G stroke:#ef4444,fill:#450a0a,color:#fca5a5
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.
Side-by-Side Comparison: When Each Approach Wins
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.
flowchart LR
Start("Privacy requirement") --> Trust{Trust all consumers?}
Trust -->|Yes| Tooling{Need reflection/serialization?}
Trust -->|No| Hard["Use # fields"]
Tooling -->|Yes| Private["Use private modifier"]
Tooling -->|No| Either["Either works"]
Private --> DevEx("Better IDE support")
Hard --> Security("Runtime guarantees")
Either --> Hybrid("Consider hybrid approach")
style Hard stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style Private stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style Security stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
Use TypeScript private when:
- Building internal applications where every consumer uses TypeScript with strict compiler checks enabled.
- Working with ORMs, serializers, or reflection-based frameworks that enumerate properties to generate database schemas or API payloads.
- Targeting legacy environments like ES5 where
#field polyfills add unacceptable bundle weight. - Prioritizing developer experience and autocomplete over runtime security.
Use ECMAScript # fields when:
- Publishing libraries to npm where JavaScript consumers might import the transpiled code.
- Storing sensitive data like authentication tokens, encryption keys, or payment details that must resist inspection.
- Working in plugin architectures where untrusted third-party code runs in the same runtime.
- Building frameworks or SDKs that enforce API contracts through hard encapsulation rather than documentation.
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.
Real-World Code: Implementing Both Patterns
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.
flowchart LR
A("APIClient instance") --> B("baseURL: public")
A --> C("requestCache: private")
A --> D("#authToken: # field")
C --> E("Test can access via assertion")
D --> F("No external access ever")
E --> G("Cache inspection in DevTools")
F --> H("Token protected from reflection")
style F stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style H stroke:#34d399,fill:#0b3b2e,color:#d1fae5
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.
Migration Strategies and Team Conventions
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.
flowchart LR
A("Identify security-critical fields") --> B("Audit reflection usage")
B --> C{Breaking changes acceptable?}
C -->|No| D("Keep private modifiers")
C -->|Yes| E("Migrate to # fields incrementally")
D --> F("Document privacy contract")
E --> G("Update tests first")
G --> H("Migrate one module at a time")
H --> I("Verify serialization still works")
style I stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The migration path for a library:
- Audit which fields genuinely need runtime privacy versus compile-time visibility checking.
- Publish a major version bump documenting the API surface change.
- Migrate security-critical fields to
#syntax in a feature branch. - Update internal tests to stop relying on direct field access.
- Verify serialization libraries and ORMs still function with the new field structure.
- Ship with comprehensive release notes explaining the breaking change.
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:
- Use
#for authentication tokens, encryption keys, database credentials, or personally identifiable information. - Use
privatefor caching layers, configuration objects, internal state machines, or computed properties. - Document the decision in code review guidelines and onboarding materials.
- Run linter rules that flag suspicious patterns like storing passwords in
privatefields.
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.
Frequently Asked Questions
Can I mix 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.
Do # 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.
What happens to # 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.
Can subclasses access parent class # 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.
Should I migrate existing codebases from 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.
Choosing the Right Privacy Model for Your Codebase in 2026
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.