Classes with private `#field`s are not treated as distinct when returned from functions
Closed this issue Β· 3 comments
π 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
π» 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 errorThe 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 errorThe 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
- (73%) microsoft/typescript#40781: Anonymous classes do not have distinct private members
- (73%) microsoft/typescript#46375: Member classes with ES2020 private fields do not respect assignment compatibility
- (73%) microsoft/typescript#56146: Wrong assignability of class types from expressions with private fields
- (70%) microsoft/typescript#24885: Override interfaces by declaration files to omit unexpected nominal types
- (70%) microsoft/typescript#44775: `implement Class` complains about missing #private fields
- (69%) microsoft/typescript#41788: Incorrect output for #private with nested class in ESNext
- (69%) microsoft/typescript#16556: Private properties required when assigning an instance of a different class with the same public interface
- (69%) microsoft/typescript#38050: private class field declaration generates #private in dts
- (69%) microsoft/typescript#35888: ES private fields shouldn't affect type compatibility
- (68%) microsoft/typescript#43711: Missing type error for invalid use of #private field
- (68%) microsoft/typescript#46006: bug with anonymous classes in extends for interfaces
- (68%) microsoft/typescript#35943: Private named instance fields are not removed from object spread
- (67%) microsoft/typescript#53558: Typescript treats private members of a class differently when resolving from d.ts or from a ts file
- (67%) microsoft/typescript#42683: Private properties make class instance types incompatible
- (67%) microsoft/typescript#55764: Inferring type from a class private property doesn't working if hash (#) is used
If your issue is a duplicate of one of these, feel free to close this issue. Otherwise, no action is needed.