#private fields
Declares a hard-private class member that JavaScript itself enforces at runtime, not just at compile time.
class C {
#field: Type = initializer;
#method(): R { }
static #staticField: Type;
}
// non-throwing presence test (TypeScript 4.5+)
#field in obj- Added in TypeScript 3.8
Parameters
| Parameter | Type | Description |
|---|---|---|
$#field | private name | The member name. The leading # is part of the syntax, not a string key, and is scoped uniquely to the class body that declares it. |
$obj | object | Left operand of the `in` brand check. Returns true only when obj was constructed by the class that declares #field, and narrows obj to that class. |
Errors
Reading or writing a #field on an object that was not constructed by the declaring class throws a TypeError at runtime rather than returning undefined.
Changelog
- PHP 3.8ECMAScript private fields introduced. Requires a target of ES2015 or higher; below that the downlevel emit would need WeakMaps that cannot be polyfilled without leaking.
- PHP 4.3# names extended beyond fields to methods, getters, setters and static members.
- PHP 4.5Ergonomic brand checks: `#field in obj` tests for the field without throwing, and narrows obj to the declaring class.
A member whose name begins with # is a real ECMAScript private field. TypeScript's private modifier is erased at compile time and can be defeated with obj["field"] or by any plain JavaScript caller; a # name is enforced by the engine itself and there is no escape hatch.
When to reach for it
- You publish a library and untyped JavaScript consumers must not reach your internals.
- You need a brand check.
#field in objproves an object was constructed by your class, which no structural type test can do. - A subclass might reuse a name.
#names never collide across classes, even when spelled identically.
What trips people up
The name is syntax, not a string: this["#count"] is a different, ordinary property. Private fields are invisible to Object.keys, object spread and JSON.stringify, so a class that keeps all its state privately serialises to {} and nobody warns you. Touching the field on the wrong receiver throws a TypeError instead of yielding undefined — which is exactly why the in form exists as the safe test. Targets below ES2015 are rejected outright, and the WeakMap-based downlevel emit costs a lookup on every access in hot code.
Example
When to use it
- Hide a connection handle or cache from JavaScript consumers of a published package.
- Brand-check an unknown value with `#field in obj` before trusting it as your own instance.
- Give a subclass its own #id without colliding with the base class's #id.
Frequently asked questions
What is the difference between private and # in TypeScript?
`private` is a compile-time-only marker that is erased on emit, so `obj["field"]` or any JavaScript caller can still read the value; `#` is enforced by the JavaScript engine and cannot be reached from outside the class body at all. `private` also works with any target including ES3, while `#` requires ES2015 or higher.
Why does my class with private fields serialise to an empty object?
Because `#` fields are not own enumerable properties, and JSON.stringify only walks own enumerable properties. Add a `toJSON()` method that returns a plain object built from the private fields, or expose the values through getters and serialise those.
Why do I get 'Cannot read private member' at runtime?
You called a method with the wrong receiver — typically by destructuring or passing the method as a callback, so `this` is no longer an instance of the declaring class. Bind the method, wrap the call in an arrow function, or use `#field in obj` first to check the receiver without throwing.
More examples
Private methods, accessors and static members
Both instances print "tok-****" and the static counter prints 2. #mint and #masked never appear on Session.prototype, so no caller — typed or untyped — can reach them. Static private members like #issued belong to the constructor object, so subclasses do not get their own copy.
class Session {
static #issued = 0;
#token: string;
constructor() {
Session.#issued++;
this.#token = this.#mint(Session.#issued);
}
// private method: not on the public prototype at all
#mint(seq: number): string {
return `tok-${String(seq).padStart(4, "0")}`;
}
get #masked(): string {
return this.#token.slice(0, 4) + "****";
}
describe(): string { return this.#masked; }
static get issued(): number { return Session.#issued; }
}
console.log(new Session().describe());
console.log(new Session().describe());
console.log(Session.issued);Why the `in` check exists
Prints true, then false, then {}. equalsUnsafe would have thrown a TypeError on the plain object literal; the brand check returns false instead. The final line shows the serialisation trap: Money holds one field and stringifies to an empty object, because #cents is not an own enumerable property.
class Money {
#cents: number;
constructor(cents: number) { this.#cents = cents; }
// Unsafe: other might not be a Money, and reading #cents would throw.
equalsUnsafe(other: any): boolean {
return this.#cents === other.#cents;
}
// Safe: the brand check narrows `other` to Money before the read.
equals(other: unknown): boolean {
return #cents in (other as object) && this.#cents === (other as Money).#cents;
}
}
const a = new Money(500);
console.log(a.equals(new Money(500)));
console.log(a.equals({ cents: 500 }));
console.log(JSON.stringify(a));
Discussion