bastianplsfix

Private class members

There are two kinds of private here, they are unrelated, and choosing between them is the entire point of this entry.

#name is a private slot. It is enforced by the runtime, invisible to every operation that lists or serialises, and reachable only by code written inside the class body. Not by subclasses. Not by casts. TypeScript's private is a visibility annotation: enforced by the checker among code that is being checked, and it erases completely, leaving an ordinary property behind.

Both are useful. They are just answers to different questions: private says "you should not touch this", #name says "you cannot".

Create programs/private-class-members.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

fields, methods, and accessors can all be private

Deno.test("fields, methods, and accessors can all be private", () => {
class Counter {
#count = 0;
#limit: number;

constructor(limit: number) {
this.#limit = limit;
}

#atLimit(): boolean {
return this.#count >= this.#limit;
}

get #remaining(): number {
return this.#limit - this.#count;
}

inc(): boolean {
if (this.#atLimit()) return false;
this.#count++;
return true;
}

get left(): number {
return this.#remaining;
}
}

const counter = new Counter(2);

assert(counter.inc());
assert(counter.inc());
assertFalse(counter.inc());
assertStrictEquals(counter.left, 0);
});
Check programs/private-class-members.test.ts
running 1 test from ./programs/private-class-members.test.ts
fields, methods, and accessors can all be private ... ok (0ms)

ok | 1 passed | 0 failed (2ms)

Anything a class can have publicly it can have privately: two fields, a method, and an accessor, all behind #. Note the one rule that differs from properties: a private field must be declared in the class body before it can be assigned, which is why #limit appears on its own line even though the constructor is what fills it. That requirement exists because the # names have to be known when the class is created, and it is a small improvement on properties, where a typo in this.tihs = x just makes a new property.

the hash is part of the name

Deno.test("the hash is part of the name", () => {
class Hidden {
#x = 1;

static hasSlot(value: object): boolean {
return #x in value;
}
}

const hidden = new Hidden();

assert(Hidden.hasSlot(hidden));
assertFalse("#x" in (hidden as unknown as Record<string, unknown>));
assertEquals(Reflect.ownKeys(hidden), []);
});
the hash is part of the name ... ok (0ms)

#x is not shorthand for the string key "#x". There is no such property, so the string in check says no and Reflect.ownKeys, which the classes page showed returning everything a class instance owns, returns nothing at all. The # is part of an identifier, and private slots are stored separately from properties, with keys you cannot get hold of. That is what makes the guarantee real rather than conventional, and the #x in value spelling inside hasSlot is the one way to ask from code that is allowed to.

private erases, and the hash does not

Two classes, one secret each, one spelled private secret and one spelled #secret. Predict what JSON.stringify does with the first:

Deno.test("private erases, and the hash does not", () => {
class TsPrivate {
private secret = "visible";

peek(): string {
return this.secret;
}
}
class HashPrivate {
#secret = "hidden";

peek(): string {
return this.#secret;
}
}

const soft = new TsPrivate();
const hard = new HashPrivate();

assertEquals(Reflect.ownKeys(soft), ["secret"]);
assertStrictEquals(soft["secret"], "visible");
assertStrictEquals(JSON.stringify(soft), "{}");

assertEquals(Reflect.ownKeys(hard), []);
assertStrictEquals(JSON.stringify(hard), "{}");
assertStrictEquals(hard.peek(), "hidden");
});
Check programs/private-class-members.test.ts
running 3 tests from ./programs/private-class-members.test.ts
...
private erases, and the hash does not ... FAILED (8ms)

ERRORS

private erases, and the hash does not => ./programs/private-class-members.test.ts:62:11
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- {"secret":"visible"}
+ {}

FAILURES

private erases, and the hash does not => ./programs/private-class-members.test.ts:62:11

FAILED | 2 passed | 1 failed (10ms)

error: Test failed

The secret is in the output. private is a rule for the checker only, and it erases completely, leaving an ordinary property behind: listed by Reflect.ownKeys, reachable through bracket notation, and included in JSON.stringify. The bracket access on the line above is not even a hack; TypeScript deliberately permits soft["secret"] as an escape hatch, with no cast anywhere, so private is a rule you can decline to follow. It protects against mistakes, not against anyone. Correct the prediction to '{"secret":"visible"}':

private erases, and the hash does not ... ok (0ms)

The serialisation line is the one that changes decisions, and it cuts both ways. If your class holds a token in a private field, that token is in the log the moment somebody stringifies the object. If it holds one in #token, the object serialises to {} and you have to write your own toJSON. Neither is the right answer in general; the wrong answer is not knowing which one you picked.

a private method is per instance, and still shared

Deno.test("a private method is per instance, and still shared", () => {
class Shared {
#act(): string {
return "acted";
}

static whereIsIt(instance: Shared): [boolean, boolean, boolean] {
return [#act in instance, #act in Shared.prototype, #act in Shared];
}

static isShared(a: Shared, b: Shared): boolean {
return a.#act === b.#act;
}
}

assertEquals(Shared.whereIsIt(new Shared()), [true, false, false]);
assertEquals(Reflect.ownKeys(Shared.prototype), ["constructor"]);

assert(Shared.isShared(new Shared(), new Shared()));
});
a private method is per instance, and still shared ... ok (0ms)

A private method is an odd hybrid, and the three brand checks say where it sits: on the instance, not on the prototype, not on the class. Unlike a public method, it cannot live on .prototype, because private slots are not inherited and putting it there would make it unreachable. Yet isShared shows two instances holding the identical function object, exactly what a prototype method would give you: one function, stored per instance as a slot. Private methods cost you nothing in memory terms and still cannot be reached from outside, so there is no reason to avoid them.

a subclass cannot even name a super private slot

Write the obvious inheritance mistake into the file:

Deno.test("a subclass cannot even name a super private slot", () => {
class SuperClass {
#superProp = "superProp";
}
class SubClass extends SuperClass {
getSuperProp(): string {
return this.#superProp;
}
}

assertStrictEquals(new SubClass().getSuperProp(), "superProp");
});
Check programs/private-class-members.test.ts
TS18013 [ERROR]: Property '#superProp' is not accessible outside class 'SuperClass' because it has a private identifier.
return this.#superProp;
~~~~~~~~~~
at file:///programs/private-class-members.test.ts:117:21

error: Type checking failed.

TS18013, and this is stricter than private in most languages, and stricter than anything TypeScript offers, because TypeScript at least has protected for the case where a subclass should be let in. There is no #protected. A private slot is visible in one class body and nowhere else, full stop.

No @ts-expect-error can rescue this one, because the runtime's refusal is stronger still: not a TypeError when the line runs, a SyntaxError when the code is compiled, so a file containing the mistake would not load at all. Delete the classes and observe the refusal the only way possible, by compiling a string:

Deno.test("a subclass cannot even name a super private slot", () => {
const source =
"class A { #x = 1; } class B extends A { m() { return this.#x; } }";

const error = assertThrows(() => new Function(source), SyntaxError);

assertStrictEquals(
error.message,
"Private field '#x' must be declared in an enclosing class",
);
});
a subclass cannot even name a super private slot ... ok (0ms)

A SyntaxError is the strongest objection available, and the reason it is available is the previous step: #x is an identifier resolved lexically, so a body that does not declare it cannot form a reference to it. The mistake is not a bad lookup; it is unpronounceable. This is the third and last place the series compiles a string on purpose, after the branching and parameters and arguments pages.

the same identifier in two classes is two names

Deno.test("the same identifier in two classes is two names", () => {
class Color {
#name: string;

constructor(name: string) {
this.#name = name;
}

static nameOf(color: Color): string {
return color.#name;
}
}
class Named {
#name: string;

constructor(name: string) {
this.#name = name;
}

describe(): string {
return this.#name;
}
}

assertStrictEquals(Color.nameOf(new Color("green")), "green");

assertThrows(
() => Color.nameOf(new Named("Ada") as unknown as Color),
TypeError,
"Cannot read private member #name from an object whose class did not declare it",
);
});
the same identifier in two classes is two names ... ok (0ms)

Two classes each declare #name, and Color's method throws on an object whose slot is spelled the same way, because what a # identifier refers to is a unique key created when its class was created, not the text you typed. Think of the identifier the way you think of a variable name: x in two functions is two variables, and this is the same rule for slots. Private names can never collide, ever, including with a library you have not read. Note what the double cast could not do: the pinned message is a runtime TypeError even though the value was claimed all the way to Color.

a brand check, and a reused identifier

Deno.test("a brand check, and a reused identifier", () => {
class Color {
#name: string;

constructor(name: string) {
this.#name = name;
}

describe(): string {
return this.#name;
}

static isColor(value: object): boolean {
return #name in value;
}
}
class Named {
#name = "Ada";

describe(): string {
return this.#name;
}
}

assert(Color.isColor(new Color("green")));
assertFalse(Color.isColor(new Named()));
assertFalse(Color.isColor({ name: "green" }));

class SuperF {
#f = "super";

readSuper(): string {
return this.#f;
}
}
class SubF extends SuperF {
#f = "sub";

readSub(): string {
return this.#f;
}
}

const both = new SubF();

assertStrictEquals(both.readSuper(), "super");
assertStrictEquals(both.readSub(), "sub");
});
a brand check, and a reused identifier ... ok (0ms)

Two consequences of two-names-per-spelling, both worth knowing. First, a brand check that actually works: #name in value asks whether this object has my class's slot, and unlike instanceof it does not depend on holding the right class object, cannot be fooled by a lookalike shape, and cannot be answered by Symbol.hasInstance. The prototypes and inheritance page's instanceof can be answered by anything is why that list matters; when the question is really "did my code make this", the brand check is the tool. Second, a subclass may reuse the identifier: both is one object with two slots both written #f, each readable from the class that declared it. In a language with inherited private fields this would be a shadowing problem; here there is nothing to shadow.

the name is scoped where it was written

Deno.test("the name is scoped where it was written", () => {
class Escapee {
#data = "hello";

static makeReader(): (e: Escapee) => string {
return (e: Escapee) => e.#data;
}
}

const read = Escapee.makeReader();

assertStrictEquals(read(new Escapee()), "hello");
});
the name is scoped where it was written ... ok (0ms)

makeReader returns an arrow function that reads #data, and it still works after being handed out. Access is not about where the code runs, or what object this is; it is about where the code was written. This is the lexical rule from the closures page's a function carries its birth scope applied to names rather than variables, and it is how a class can hand a capability to something outside itself without exposing the state. Useful and worth being deliberate about, since a returned function is a permanent hole in the wall.

static private through this breaks in a subclass

The classes page measured that static members are inherited. Combine that with slots that are not, and predict the fourth call:

Deno.test("static private through this breaks in a subclass", () => {
class Config {
static #defaults = 3;

static viaName(): number {
return Config.#defaults;
}

static viaThis(): number {
return this.#defaults;
}
}
class DerivedConfig extends Config {}

assertStrictEquals(Config.viaName(), 3);
assertStrictEquals(Config.viaThis(), 3);
assertStrictEquals(DerivedConfig.viaName(), 3);

assertStrictEquals(DerivedConfig.viaThis(), 3);
});
Check programs/private-class-members.test.ts
running 9 tests from ./programs/private-class-members.test.ts
...
static private through this breaks in a subclass ... FAILED (1ms)

ERRORS

static private through this breaks in a subclass => ./programs/private-class-members.test.ts:219:11
error: TypeError: Cannot read private member #defaults from an object whose class did not declare it
return this.#defaults;
^

FAILURES

static private through this breaks in a subclass => ./programs/private-class-members.test.ts:219:11

FAILED | 8 passed | 1 failed (3ms)

error: Test failed

Three calls work and the fourth crashes. DerivedConfig.viaThis() runs the superclass's method with this set to DerivedConfig, because the receiver is whatever was left of the dot, from the value of this, and DerivedConfig has no #defaults, because private slots are not inherited. Pin the crash:

Deno.test("static private through this breaks in a subclass", () => {
class Config {
static #defaults = 3;

static viaName(): number {
return Config.#defaults;
}

static viaThis(): number {
return this.#defaults;
}
}
class DerivedConfig extends Config {}

assertStrictEquals(Config.viaName(), 3);
assertStrictEquals(Config.viaThis(), 3);
assertStrictEquals(DerivedConfig.viaName(), 3);

assertThrows(
() => DerivedConfig.viaThis(),
TypeError,
"Cannot read private member #defaults from an object whose class did not declare it",
);
});
static private through this breaks in a subclass ... ok (1ms)

So inside a static method, write the class name rather than this whenever a private slot is involved. It is the one place where the usual advice to prefer this is wrong, and the failure only appears once somebody subclasses you.

a private slot does not survive a structured clone

Deno.test("a private slot does not survive a structured clone", () => {
class Carried {
visible = "kept";
#secret = "lost";

forget(): string {
return this.#secret;
}

static hasSecret(value: object): boolean {
return #secret in value;
}
}

const original = new Carried();
const copy = structuredClone(original);

assertStrictEquals(copy.visible, "kept");
assert(Carried.hasSecret(original));
assertFalse(Carried.hasSecret(copy));
});
a private slot does not survive a structured clone ... ok (0ms)

The public property crosses and the private slot does not, the same boundary the prototypes and inheritance page measured in a clone leaves the chain behind and the designing error types page meets again with error classes. A clone is built from copied data, and there is no way to hand it a key it cannot name. Combine this with the serialisation result earlier and the rule is simple: # state does not leave the process. If an object has to be sent anywhere, its private slots are not part of what arrives.

module scope is the missing protected

There is no #protected, and reaching for TypeScript's private from a subclass shows what the checker thinks of that idea:

Deno.test("module scope is the missing protected", () => {
class Soft {
private secret = "visible";
}
class SoftHeir extends Soft {
reach(): string {
return this.secret;
}
}

assertStrictEquals(new SoftHeir().reach(), "visible");
});
Check programs/private-class-members.test.ts
TS2341 [ERROR]: Property 'secret' is private and only accessible within class 'Soft'.
return this.secret;
~~~~~~
at file:///programs/private-class-members.test.ts:272:21

error: Type checking failed.

TS2341 is the checked version of the wall, and TypeScript's protected would relax it with no run-time guarantee behind it. The run-time answer is different: keep the shared state in module scope, and the scope of privacy becomes the module rather than the class:

Deno.test("module scope is the missing protected", () => {
const shared = new WeakMap<object, string>();

class Protected {
constructor() {
shared.set(this, "visible to the module");
}
}

class ProtectedHeir extends Protected {
read(): string | undefined {
return shared.get(this);
}
}

const heir = new ProtectedHeir();

assertStrictEquals(heir.read(), "visible to the module");
assertEquals(Reflect.ownKeys(heir), []);
assertStrictEquals(JSON.stringify(heir), "{}");
});
module scope is the missing protected ... ok (0ms)

Invisible from outside, readable by the subclass, and it costs a WeakMap and some ceremony. A class and its subclasses, or a class and its designated collaborators, share access as long as they share a file, which in real code means module scope rather than a step's scope. Pick between the two by whether you need a rule or a wall.

In practice