skip to content

In TypeScript, why is an instance of `class Meters { constructor(private value: number) {} }` not assignable to `class Feet { constructor(private value: number) {} }`, even though the two classes have identical shapes?

level: middleimportance: nice to knowfreq 30%

answer

  1. structural everywhere, with one exception
  2. privacy is not part of the public contract
  3. matched by where it was declared
  4. subclasses inherit the same declaration
  5. one unused private field seals a class

basics

~20 s

Private and protected members are matched by declaration, not by name and type. Each class declares its own value, so the two never match and the classes compare nominally instead of structurally — the one place TypeScript is nominal by default.

solid answer

~50 s

TypeScript's assignability is structural everywhere except here: when a type has a `private` or `protected` member, the checker requires both sides to have that member from the **same declaration**. `Meters` and `Feet` each declare their own `value`, so neither is assignable to the other and the compiler says the two have separate declarations of a private property. Inheritance is the exception that proves the rule — a subclass of `Meters` inherits the very same declaration, so it still assigns to `Meters`. That makes a private member the language's built-in nominal-emulation tool: adding one unused `private` field to a wrapper class makes it non-interchangeable with any lookalike. The tradeoff versus a phantom brand is runtime cost — a wrapper class allocates an object per value and forces unwrapping at every use — so brands are preferred for primitives, and a private-member class when you also want real encapsulation or behaviour attached.

code

typescript · 17 lines
typescript
class Meters {
  constructor(private readonly value: number) {}
  get raw(): number { return this.value; }
}

class Feet {
  constructor(private readonly value: number) {}
  get raw(): number { return this.value; }
}

class Kilometres extends Meters {}

declare function jump(height: Meters): void;

jump(new Meters(3));
jump(new Kilometres(3)); // ok: inherits the same private declaration
// jump(new Feet(3));    // error: separate declarations of 'value'

go deeper

for a junior

Know that adding a private member makes two otherwise identical classes incompatible, and that TypeScript is otherwise structural — shape alone normally decides assignability.

for a middle

Explain the mechanism precisely: private and protected members must come from the same declaration, which is why a subclass still matches its base but an identical unrelated class does not.

for a senior

Use it deliberately as nominal emulation, and weigh it against a phantom brand — allocation per value and unwrapping at boundaries versus a zero-cost marker on a primitive.

for a principal

Decide when domain values deserve real types at all, and set a consistent house rule so one codebase does not mix wrapper classes, branded primitives and bare strings for the same concept.

## The rule TypeScript compares types structurally: two types are compatible when their members line up. Private and protected members are the deliberate exception. When comparing two types, the checker requires that a `private` or `protected` member on one side **originates from the same declaration** as the corresponding member on the other. Two independent classes, even character-for-character identical, have two different declarations, so they never match. ```ts class Meters { constructor(private readonly value: number) {} } class Feet { constructor(private readonly value: number) {} } declare function jump(height: Meters): void; jump(new Meters(3)); // jump(new Feet(3)); // error: the classes declare 'value' privately, separately ``` The compiler's diagnostic names exactly this: the types have separate declarations of a private property. ## Why the rule exists A private member is, by definition, not part of the type's public contract — outside code cannot read or write it. If it were compared structurally by name and type, then any class that happened to have a private field of the same name would be substitutable, which would silently defeat the encapsulation the modifier was asked to provide. Requiring declaration identity means "only things that actually inherit this implementation count". That is also why inheritance still works: ```ts class Kilometres extends Meters {} jump(new Kilometres(3)); // fine: inherits the same 'value' declaration ``` `Kilometres` did not redeclare `value`; it inherited it, so the declaration is shared and assignability holds. Redeclaring the member in the subclass would break it again. ## Using it deliberately: nominal emulation Because the rule is about declaration identity rather than about privacy being useful, you can exploit it. Adding a single private member to a class makes that class non-interchangeable with every lookalike, which is the class-based counterpart to a phantom brand on a primitive: ```ts class Currency { private readonly nominal!: void; constructor(readonly amount: number, readonly code: string) {} } ``` The `nominal` member exists only to make the type unique. Note the definite-assignment `!` — the field is never assigned, so the compiler needs to be told not to complain under strict initialization checking, and `void` is used because no value will ever be stored. ## The `#private` variant ECMAScript private fields written with `#` are a genuine runtime feature rather than a compile-time modifier, and they too make class types non-interchangeable: a class with `#value` is not assignable to a different class with `#value`, because the field is not a member the other type has. The difference is what happens at emit — a `private` modifier is erased and the property is an ordinary, fully accessible property in the emitted JavaScript, while `#value` is really inaccessible at runtime. For the assignability question they behave the same way; for actual encapsulation only `#` delivers. ## Compared with a phantom brand Both achieve nominality; they cost differently. | | Private-member class | Phantom brand on a primitive | |---|---|---| | Runtime cost | one object allocation per value | none, value stays a primitive | | Use sites | unwrap via a property or method | value used directly | | Encapsulation | real (with `#`), can carry behaviour | none, it is still a string | | Interop | must unwrap for JSON, map keys, logging | works everywhere a primitive works | So: brand a primitive when the value *is* a primitive and you only want the compiler to stop confusing two of them. Reach for a class with a private member when the value has behaviour, invariants worth hiding, or a genuine need for runtime encapsulation. ## Traps worth knowing - **Two classes generated from the same generic factory function share the declaration.** If the class is produced inside a function, every call returns a class whose members come from the same source declaration, so instances of different calls may still be mutually assignable. - **Interfaces cannot declare private members**, so this technique is class-only. An interface describing a class with private members can never be implemented by anything else, which is occasionally used to seal a type. - **The error message confuses readers.** "Types have separate declarations of a private property" reads like a bug report when it is in fact the feature working; a comment on the marker member saves the next reader a search.

  • If two classes both declare a private member, why does a subclass still assign to its base?
    Because the subclass does not redeclare the member — it inherits it, so both types trace the member back to the same declaration and the identity check passes. Redeclare `private value` in the subclass and assignability breaks again, which surprises people who add a field of the same name for convenience. The rule is about declaration origin, not about the member's name or type.
  • Can you get the same nominal effect with an interface instead of a class?
    No — interfaces cannot declare `private` or `protected` members, so there is nothing to anchor declaration identity to and comparison stays purely structural. For interface-shaped data the equivalent tool is the phantom brand: intersect the interface with a marker property. The private-member trick is class-only.
  • When would you prefer a wrapper class with a private member over branding the primitive?
    When the value deserves behaviour or real encapsulation — a Money type with arithmetic that must not mix currencies, or a value whose raw form should be genuinely unreachable, which needs a `#` field. Accept the costs: an allocation per value, unwrapping at every boundary, and custom handling for JSON and map keys. For a bare id or a validated string, a brand gives the same compile-time separation for free.

saying these in an interview costs you the question

  • Says TypeScript compares all classes nominally
  • Thinks private members are simply ignored during comparison
  • Expects a subclass to stop being assignable to its base
  • Believes the private modifier hides the property at runtime
  • Tries to declare a private member on an interface

context