How it works
Assignability asks whether the source type's members satisfy the target's, recursively. Names of interfaces, classes, and aliases play no part: a type alias is only a label, and the compiler compares what it expands to. For functions, the return type must be assignable, and parameters are compared by the rules in the function-types question. For generics, the handbook notes that type parameters only matter when used in a member: Empty<number> and Empty<string> with no members are mutually assignable.
Nominal-like rules are layered on specific constructs. For private and protected members the handbook says the source must contain a member that "originated from the same class". Verified with TS 7.0.2:
class SecretA { private secret = 1 }
class SecretB { private secret = 1 }
const a: SecretA = new SecretB();
// Error TS2322: Type 'SecretB' is not assignable to type 'SecretA'.
// Types have separate declarations of a private property 'secret'.
class Hidden { #id = 1 }
class Hidden2 { #id = 1 }
const h: Hidden = new Hidden2();
// Error TS2322: Type 'Hidden2' is not assignable to type 'Hidden'.
// Property '#id' in type 'Hidden2' refers to a different member that cannot be accessed from within type 'Hidden'.
Edge cases
- Empty and top-like types.
interface Empty {} accepts 42; {} accepts anything except null/undefined (Type 'null' is not assignable to type '{}'); object rejects primitives (Type 'string' is not assignable to type 'object'). All three checked on 7.0.2.
- Fewer parameters are assignable, more are not.
(a: number, b: number) => number accepts (n: number) => n, but not the reverse: Target signature provides too few arguments. Expected 2 or more, but got 1.
- Aliases of the same type are interchangeable. A "branded alias" needs an actual extra member; a different name is not enough.
- The brand is erased. After compilation there is only a string; nothing at runtime can tell a
UserId from an OrderId.
Real-world scenario
IDs and units. An API takes userId and orderId, both strings; swapping them compiles and fails in production. Brand them, and make construction the validation point:
declare const brand: unique symbol;
type Email = string & { readonly [brand]: "Email" };
function parseEmail(raw: string): Email {
if (!raw.includes("@")) throw new RangeError(`not an email: ${raw}`);
return raw as Email;
}
function send(to: Email) { console.log("sending to", to); }
send(parseEmail("a@b.co")); // → sending to a@b.co
send("a@b.co");
// Error TS2345: Argument of type 'string' is not assignable to parameter of type 'Email'.
// Type 'string' is not assignable to type '{ readonly [brand]: "Email"; }'.
Run on Node 24.19 with its built-in type stripping (type stripping is on by default; the send("a@b.co") line removed): sending to a@b.co, and parseEmail("nope") throws not an email: nope.
Trade-offs
Structural typing keeps typing cheap for JavaScript idioms (object literals, duck-typed callbacks, mocks) and lets unrelated code interoperate without shared base classes. The cost is accidental compatibility. Pick the cheapest tool that stops the mixing you care about: a branded primitive for IDs and units; a class with a #private field for objects with identity; a discriminant property (kind: "circle") for unions. Avoid brands when no runtime check backs them: they then only document intent. ❓ Whether a future TypeScript version will add native nominal types is not decided.