skip to content

In TypeScript, what does marking a class member `private` actually prevent, and how is that different from declaring the member as `#name`?

level: middleimportance: must knowfreq 80%

answer

  1. one is a checker rule, one is syntax
  2. what survives the emit step
  3. erased modifier vs emitted field
  4. TypeError-free vs genuinely unreachable
  5. bracket notation defeats exactly one

basics

~20 s

TypeScript's private is a compile-time-only check that is erased on emit, so the property still exists and plain JavaScript can read it. A #name field is a real ECMAScript private field that stays unreachable from outside the class at run time.

solid answer

~50 s

`private` is a rule the type checker enforces and then throws away. It stops you writing `obj.secret` in TypeScript source, but the emitted JavaScript contains an ordinary property, so any JS caller, `Object.keys`, `JSON.stringify` or `(obj as any).secret` reaches it. `#name` is not a type-layer feature at all — it is ECMAScript syntax that survives compilation. A private name can only be referenced from inside the class body that declares it; anywhere else it is a hard error, and at run time the field is not an own property, so enumeration and serialisation never see it. Two practical consequences follow: `private` gives you the bracket-notation escape hatch that tests often rely on, and it has a `protected` sibling for subclasses, while `#` has neither. Assuming TypeScript 5.x, `#` fields emit natively when the target is ES2022 or later and are downlevelled with WeakMaps below that.

code

typescript · 15 lines
typescript
class Account {
  private balance = 100;
  #pin = "1234";

  compare(other: Account): boolean {
    // both kinds are readable from inside the declaring class
    return other.balance > 0 && other.#pin.length === 4;
  }
}

const a = new Account();
// a.balance;             // error: 'balance' is private
// a.#pin;                // error: '#pin' is not declared in an enclosing class
console.log(a["balance"]); // 100 - bracket access is an accepted escape hatch
console.log(new Account().compare(a));

go deeper

for a junior

Be able to say plainly that private is checked by the compiler and then erased, while a #name field stays private when the code runs. Knowing which of the two a JavaScript caller can reach is enough at this level.

for a middle

Explain the mechanics: private is a modifier deleted at emit, leaving an ordinary property; #name is ECMAScript syntax scoped to the class body and stored outside the property table. Mention the bracket-notation hatch and that # has no protected tier.

for a senior

Show judgment about where the boundary has to hold: secrets that must not reach logs, spreads or JSON belong in #, ordinary implementation detail in private. Be ready to say that neither one is a security control against code running in the same process.

for a principal

Own the API-surface tradeoff for published packages: private leaks to untyped JavaScript consumers and still appears in the .d.ts, while # hardens the boundary at the cost of subclass extension points, testability and downlevel emit weight. Have a position on which default your codebase enforces and why.

## Two mechanisms, one English word Both `private x` and `#x` are read aloud as "a private field", but they belong to different layers of the stack. `private` is part of TypeScript's **type layer**: a modifier the checker understands and the emitter deletes. `#x` is part of **JavaScript itself** — a private name, which is syntax the engine implements. TypeScript understands `#x` and type-checks it, but it does not own it, and it cannot erase it. ## What the compiler does with `private` During checking, `private` restricts where the member may be referenced: only inside the body of the class that declares it. During emit, the modifier is removed and nothing replaces it. ```ts class Account { private balance = 100; } ``` emits, in essence: ```js class Account { balance = 100; } ``` So the field is a normal own property of the instance. `Object.keys(account)` lists it, `{ ...account }` copies it, `JSON.stringify` serialises it, and a JavaScript consumer of your compiled package writes `account.balance` with no complaint at all. TypeScript never installs a runtime check — that is the whole point of erasure, and it is what makes `private` free. There is one deliberate hole even at compile time: element access with a string literal is exempt from the check, so `account["balance"]` compiles (and is typed as `number`, not `any`). That hatch exists mainly so tests and interop code can reach in without an `any` cast. ## What `#name` is `#balance` declares a *private name*. Private names are lexically scoped to the class body: `other.#balance` is legal inside `Account` and is a compile error anywhere else — TypeScript reports that the private field must be declared in an enclosing class, and a raw JavaScript engine treats it as a syntax error. At run time the field is stored as an internal slot rather than as a property, so it does not appear in `Object.keys`, spread, or `JSON.stringify`. ```ts class Account { #pin = "1234"; matches(other: Account) { return this.#pin === other.#pin; // fine: same class body } } ``` Note that privacy here is per-class, not per-object: one `Account` may read another `Account`'s `#pin`, exactly as it may read another's `private` member. Assuming TypeScript 5.x, the emit depends on `target`: at ES2022 and above the `#` syntax is emitted as written; below that, the compiler downlevels it using WeakMaps to preserve the semantics, which costs a little code size and indirection. ## The differences that matter in an interview 1. **Runtime enforcement.** `private` has none; `#` has real enforcement backed by the engine. 2. **Enumeration and serialisation.** A `private` field is an ordinary property and shows up in logs, spreads and JSON; a `#` field does not. 3. **Escape hatches.** `obj["x"]`, `as any` and plain JS all defeat `private`. Nothing in the language defeats `#`. 4. **Assignability.** `private` and `protected` members make class types compare *nominally*: two classes with identical shapes are mutually assignable only if the private member comes from the same declaration. `#` fields have the same effect on structural compatibility, so both break naive duck typing. 5. **Inheritance.** `private` has a sibling modifier, `protected`, that opens the member to subclasses. Private names have no such tier — a subclass cannot see the base's `#x`. 6. **Declaration output.** A `private` member is still recorded in the generated `.d.ts` (it affects assignability); it is not a secret from consumers of your types. ## Choosing between them Use `private` for ordinary encapsulation inside your own codebase: it is cheaper to write, it is testable through the bracket hatch, and it pairs with `protected`. Reach for `#` when the value must genuinely not leak — credentials, internal handles, anything you do not want in a serialised payload or a log line — or when you publish a class to untyped JavaScript consumers and want the boundary to hold for them too. What you must never say in an interview is that `private` "hides" the value at run time. It hides it from the checker, and that is a code-quality tool, not a security boundary.

  • If `#` is the stronger guarantee, why not use it for every private member?
    Because you lose things you often want. There is no `protected` equivalent, so subclasses cannot reach a `#` member; tests can no longer use the bracket-notation hatch and must go through the public surface; and below an ES2022 target the compiler downlevels private names with WeakMaps, which adds code. `private` remains the right default for ordinary encapsulation inside a codebase you control.
  • Two classes declare exactly the same members, and each declares `private id: string`. Is one assignable to the other?
    No. Private and protected members are compared nominally: assignability requires that the member originate from the *same* declaration, so two independently declared `private id` fields make the classes incompatible even though their shapes match. Only a subclass, which inherits the original declaration, stays assignable to the base.
  • Does a `private` member show up in the generated `.d.ts` file?
    Yes. Declaration emit records the member as private, because its presence changes assignability between class types and consumers need that information. It does not expose the implementation, but it does tell you the member exists — another reason `private` is not a secrecy mechanism.

private is a sign on a door saying "staff only"; # is a lock. The sign is honoured by anyone reading your TypeScript source, and ignored by everything else.

saying these in an interview costs you the question

  • Claims private makes the value unreachable at run time
  • Thinks TypeScript inserts runtime checks to enforce private
  • Says #x is just a naming convention like _x
  • Believes private members are stripped from the emitted object
  • Assumes a subclass can read the base class's #x

context