TypeScript access boundaries: compile-time private members and runtime-reachable exports
0 reputation · 05 Apr 2024, 22:59 UTC
0 reputation · 05 Apr 2024, 22:59 UTC
TypeScript's private and protected class modifiers are a compile-time contract. They are erased in emitted JavaScript, so the member remains an ordinary property at runtime. ECMAScript #private fields are enforced by the engine, but they are not erasable, cannot be reached through bracket notation or reflection, and depend on the compilation target.
Module scope adds a separate boundary: TypeScript has no module-private modifier, so any exported declaration becomes part of the public surface. Un-exported declarations stay internal, but nothing prevents a later export.
The unresolved decision is which boundary a codebase should rely on when a member must not be reachable by consumers — the ergonomic compiler-only check, or the stricter runtime guarantee with its constraints on target and tooling. Behavior is assumed for stable TypeScript 5.x with strict enabled; emitted output can be inspected to confirm erasure versus native #private emission.
Which mechanism fits a library whose consumers may compile with different targets? Should protected inheritance be combined with runtime privacy, and at what cost? Which target and strict settings keep the chosen boundary predictable?
35275 reputation · 06 Apr 2024, 02:19 UTC
If a member must not be reachable by consumers at runtime, TypeScript's private and protected modifiers cannot deliver that guarantee—they are erased during compilation and the emitted JavaScript exposes an ordinary property. Only ECMAScript #private fields provide engine-enforced privacy, provided your emitted output preserves them. For a library whose consumers compile with different targets, use #private for members that must be unreachable, set your published output to ES2015 or higher, and treat private/protected as IDE ergonomics rather than security.
TypeScript's access modifiers exist only in the type system. After tsc runs, private cache becomes this.cache—reachable via bracket notation or reflection. This is by design: TypeScript types are fully erasable.
#private fields are a JavaScript language feature. The engine itself throws a TypeError on external access. However, the guarantee depends on the compilation target: when TypeScript targets below ES2015, it downlevels #private to a WeakMap-based shim. While this blocks casual access, it is generated code rather than native enforcement. The boundary is only as strong as the final artifact the consumer runs.
target: "ES2015" or newer (ES2022 is a modern floor) so #private is emitted natively.#private for members that must be unreachable; use private/protected for members where compile-time discipline suffices..d.ts declarations so TypeScript consumers still receive type checking against your public surface.protected and #private compose awkwardly because #private fields are invisible even to subclasses. The standard pattern is to keep storage #private and expose a protected accessor:
class Base {
#state = 0;
protected get state() { return this.#state; }
protected bump() { this.#state++; }
}The cost is a small amount of indirection and the requirement that subclasses use the accessor rather than direct field access. In exchange, subclasses maintain controlled access while external consumers face a hard runtime boundary.
strict mode does not affect privacy enforcement; it only controls type-checking diagnostics. The critical settings are target (ES2015+) and the consumers' downstream bundler settings. Verify the shipped artifact, not just the source:
tsc --target ES2015 --strict demo.ts
node -e "const D = require('./demo.js').default; const d = new D(); console.log(d.secret)"Accessing a private member this way will succeed; accessing a native #private field will throw. Inspect the emitted file to confirm whether #field syntax survived or was downleveled to a WeakMap.
This assumes stable TypeScript 5.x and runtimes supporting native private fields (Node 12+). If consumers re-bundle your source to ES5, no language mechanism fully hides a member; you would need renaming or closure-based encapsulation. If the goal is merely to prevent accidental misuse, compile-time private is usually sufficient.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.