microsoft/TypeScript

Classes with private `#field`s are not treated as distinct when returned from functions

Closed this issue Β· 3 comments

p2js commented

πŸ”Ž Search Terms

"class from functions", "classes returned from functions", "class type" functions, "ES2020", "private fields", "#private"

πŸ•— Version & Regression Information

I believe this behavior has not changed since the introduction of private #fields in classes. I reviewed the FAQ about anything relating to classes with #fields.

⏯ Playground Link

https://www.typescriptlang.org/play/?ssl=17&ssc=42&pln=1&pc=1#code/MYGwhgzhAECC0G9oGIwC5oQC4CcCWAdgOYDc0wA9gdjgK7BYU4AU6muhRAlItFgBZ4IAOlTQAvNDDQAvrIBQoSDABCvVBhqcylarnqMWbLcR5IBQ0dMnS5M+fJABTLFIzxJBJwHc4zAEQAEk4gIBT+XCSOLtAARhhqnj5+QSFhEWQA9JnQAKI4OEwYACoAngAOTtAA5LDV0ELQBBSuynhEBGCxznwUfBVV1SrVwtAACoWVOFilNaj1hP2VNXXQOE4AZk44MIxS0AAmeBtb6wSuALZOF7HbfPxgrsBgBM2ut1LAwE5QTgfQG0KF2g3jwFgIS0Gw2E8myeQKTAau34VQOPzw63+tweADc8BRaDhoCj1g55BtaAQGPiIVgflhmGZ5NAWWsXISIUooNAAHJgK7-JAadj4Yg6Kg0AxMViaDimXgWERiGxkOzyezRVwAYQkfHpjKizlcABFdXTsAaHEbyLqvL4tQEAIwZTWHDAzSoUDY2pK+Y1OjLQOE8vrbQo4IA

πŸ’» Code

class A { #a: string; constructor(a: string) { this.#a = a } }
class B { #a: string; constructor(a: string) { this.#a = a } }

let a: A = new A("Hello");
let b: B = new A("Hello"); // Error: Type 'A' is not assignable to type 'B'. Property '#a' in type 'A' refers to a different member that cannot be accessed from within type 'B'.
// Error is the desired behaviour here


function test() {
    return class Named { #a: string; constructor(a: string) { this.#a = a; } }
}

let C = test();
let D = test();

let c = new C("1");
let d: typeof c = new D("1"); // No error

πŸ™ Actual behavior

a value of type D can be assigned to a value of type C without error.

πŸ™‚ Expected behavior

C and D are treated as distinct types like A and B, so the same error happens, ie. that the property #a of type D refers to a different member that cannot be accessed from within type C (typeof c).

Additional information about the issue

This would enable the ability to create new distinct class types dynamically which would be great for when you want to make unique types that cannot be satisfied by any other value.

Duplicate of #56146.

πŸ€– Thank you for your issue! I've done some analysis to help get you started. This response is automatically generated; feel free to πŸ‘ or πŸ‘Ž this comment according to its usefulness.

Possible Relevant FAQs

How do I prevent two types from being structurally compatible?

#a is a nominal field, but its identity is tied to the single class declaration that appears in the test function’s body, not to each time test is evaluated. At the type level there is exactly one private name originating from that declaration, so every class value returned from test() shares the same private name and therefore the same nominal identity. That is why the assignment between instances of C and D succeeds.

If a fresh, mutually-incompatible type is required on every call, attach a per-call brand so that each returned class carries a unique, unmatchable property. One way is to create a new unique symbol inside the factory and place it on the class as a private field (or even a public field; the visibility does not matter for nominality):

function makeClass() {
  const brand: unique symbol = Symbol();

  return class {
    #a: string;
    private [brand]!: void; // brand makes this type unique

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

const C = makeClass();
const D = makeClass();

const c = new C("1");
const d: typeof c = new D("1"); // now an error

The brand property forces nominal incompatibility in the same way adding a branded field to two structurally identical interfaces does. Each invocation of makeClass generates a fresh unique symbol, so the resulting class types are pairwise distinct.

When and why are classes nominal?

#a is considered the same private member in both C and D because it comes from one declaration siteβ€”the class Named { #a: string … } that appears inside test. TypeScript tracks nominal identity of a private or protected member by the declaration that introduced it, not by how many times the class expression executes at runtime.

So although every call to test() returns a fresh constructor function, the type system still sees the private field as originating from the single #a declaration, which makes values of C and D interchangeable.

There is currently no built-in way to ask the checker to generate a new, unique class type on each invocation. If distinct static types are required, add an explicit brand that varies per call, for example through a type parameter:

function makeClass<Brand>() {
  return class {
    #a: string;
    private __brand!: Brand; // forces nominal uniqueness per Brand
    constructor(a: string) { this.#a = a; }
  };
}

const C = makeClass<"first">();
const D = makeClass<"second">();

const c = new C("hi");
const d: typeof c = new D("hi"); // now an error

The extra private __brand field gives each instantiation its own declaration site, so the assignments are rejected as desired.

Similar Issues

Here are the most similar issues I found

If your issue is a duplicate of one of these, feel free to close this issue. Otherwise, no action is needed.

There's an open PR #60885 by @Andarist that would make use of #fields in class expressions an error.