#private fields

Declares a hard-private class member that JavaScript itself enforces at runtime, not just at compile time.

Signature
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

ParameterTypeDescription
$#fieldprivate nameThe member name. The leading # is part of the syntax, not a string key, and is scoped uniquely to the class body that declares it.
$objobjectLeft 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 obj proves 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · typescript
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.

Example · typescript
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

  • Be the first to comment on this lesson.