Code outside a TypeScript class writes `instance['secret']` to read a member declared `private secret: string`. Does that compile, and why does TypeScript allow it?
answer
- the check is attached to one syntax form
- tests want in without losing types
- compare it with the as any route
- erasure makes JS callers a bigger hole
- only one construct closes every route
basics
~20 sYes, it compiles. TypeScript applies the private check to dot access only; element access with a string literal is a deliberate escape hatch, and it still resolves to the member's declared type rather than any.
solid answer
~50 sIt compiles, and the result is typed `string`, not `any` — the compiler resolves the literal key to the declared member and simply skips the visibility check for element access. This is intentional, not a bug: it gives tests and interop code a way to reach implementation detail without an `as any` cast that would also throw away the type. It is worth naming the other holes in the same breath: `(instance as any).secret`, an `as unknown as` cast, and any JavaScript caller of the compiled output, since the modifier is erased. The honest summary is that `private` documents intent to the type checker and is enforced only for TypeScript source using dot access. If the member must be genuinely unreachable, `#secret` is the only construct that closes all of these, because it is JavaScript syntax rather than a type-layer annotation.
code
typescript · 12 linesclass Session {
private token = "abc";
#refresh = "xyz";
}
const s = new Session();
// s.token; // error: 'token' is private
const a: string = s["token"]; // compiles, and keeps the string type
const b: unknown = (s as any).token; // compiles, but the type is thrown away
// s["#refresh"]; // no error, but it is a plain string key: undefined
// s.#refresh; // error: not declared in an enclosing class
console.log(a, b, Object.keys(s)); // ["token"] - #refresh is absentgo deeper
Know that private is checked by the compiler, not the runtime, and that writing the member name in brackets sidesteps the check. Being able to say that an as any cast does the same thing is enough at this level.
Explain that the check applies to property access syntax while string-literal element access is an intentional hatch that still resolves the declared type. List the other routes - casts, plain JS callers, enumeration - and name # as the one construct that closes them.
Show judgment about when reaching into internals is acceptable, why the typed bracket form beats an as any cast in a test, and which members are important enough to move to # because the exposure would be a real incident rather than a style complaint.
Own the codebase policy: whether lint rules forbid bracket access to internals, how testability pressure warps public APIs, and where the team draws the line between members that are merely private by convention and state that must survive contact with untyped consumers of the published build.
## The behaviour ```ts class Session { private token = "abc"; } const s = new Session(); // s.token; // error: 'token' is private and only accessible within class 'Session' const t = s["token"]; // compiles; t is string ``` The visibility check runs on property access expressions. Element access with a string-literal key resolves to the same declared member — so you keep the type — but is exempt from the check. This is long-standing, documented behaviour, not an accident of inference. ## Why the hatch exists The alternative escape route is a cast: ```ts const t = (s as any).token; // t is any ``` That works too, but it destroys the type: `t` becomes `any`, and any mistake downstream goes unchecked. The bracket form is strictly better when you must reach in, because a typo in the key is still an error and the result keeps its real type. The main legitimate use is testing — asserting on internal state without widening the class's public surface just to make it observable — plus occasional interop with libraries that dig into instances. ## The full list of holes Be able to enumerate these; interviewers use the question to see whether you understand that `private` is a checker rule: 1. **Bracket access.** `s["token"]` — no error, full type. 2. **`as any` / `as unknown as X`.** Assertions are compile-time only and perform no check. 3. **Plain JavaScript callers.** The modifier is erased on emit, so a JS consumer of your compiled package writes `s.token` freely. 4. **Enumeration and serialisation.** `Object.keys(s)`, `{ ...s }` and `JSON.stringify(s)` all include the field, because it is an ordinary own property. 5. **Structural reads through a wider type.** Assigning the instance to a type that declares the property publicly is generally blocked — private members make class types compare nominally — but reflection-style access via `Object.entries` is not. ## What is *not* a hole A subclass cannot quietly take the member over. If a base declares `private x` and a derived class declares its own `x`, the compiler rejects the class: the two are separate declarations of a private property, and the derived class is not assignable to the base. Nor can a derived class widen a private member to public. Private members also break naive structural compatibility — two classes with identical shapes are mutually assignable only when the private member originates from the same declaration — so you cannot slip past the check by declaring a lookalike interface and asserting to it, at least not without an explicit double assertion. ```ts class A { private id = 1 } class B { private id = 1 } let a: A = new B(); // error: separate declarations of a private property 'id' ``` ## What actually closes the hatch Only `#token`. A private name is JavaScript syntax scoped to the class body: `s["#token"]` looks up a string key that does not exist, `(s as any).#token` is still a syntax-level error because private names may not be referenced outside the declaring class, and the field is not an own property, so nothing enumerates it. The cost is that your tests must go through the public API, and there is no `protected` equivalent for subclasses. ## How to talk about it The strong answer is not "bracket access is a loophole to avoid". It is: `private` is a design tool the checker enforces for dot access on TypeScript source; the bracket form is a typed, deliberate hatch that is far better than `as any` when you genuinely need it; and any member whose exposure would actually hurt — a credential, a handle, anything you would not want in a log line — belongs in a `#` field, where none of the hatches apply.
- Is reaching into private state from a test a reasonable practice?Sparingly. Asserting on internals couples the test to the implementation, so the first choice is to test through the public surface. When the internal state really is the behaviour under test — a cache that must evict, a retry counter — the bracket form is the least-bad tool because it preserves types, and it beats widening the class's API purely to make the value observable.
- A base class declares `private id` and a subclass declares its own `id`. What does the compiler say?It rejects the subclass. Private and protected members compare nominally, so the two `id` declarations are considered separate properties and the derived class is not assignable to its base. The same rule stops a subclass from re-declaring an inherited private member as public.
- Does making the field `#secret` break anything you were relying on?Two things. Tests can no longer reach it at all, so they must exercise it through the public API, and subclasses lose any chance of access because private names have no `protected` tier. Below an ES2022 target the compiler also downlevels private names with WeakMaps, which adds a little emitted code.
saying these in an interview costs you the question
- Says bracket access returns any, so no check applies
- Claims the private check depends on the strict flag
- Thinks private is enforced against JavaScript callers
- Believes an as any cast performs a runtime conversion
- Says a subclass can re-declare an inherited private member as public