TypeScript 40 ๐ท Structural Typing vs Nominal Typing
Every type system answers one question the same way: is this value assignable to that type? The answer depends on whether the system is structural โ comparing shapes โ or nominal โ comparing names. TypeScript is structural. { x: number; y: number } is compatible with any other type that has x: number and y: number, regardless of what either is called. Java, C#, and Rust are nominal โ class Point is only assignable to types that explicitly name Point, or a supertype of it. This chapter is about what structural typing means, where it diverges from nominal typing, why TypeScript made that choice, and where the differences cause friction.
Key point: Structural typing compares shape, not name. Two types with the same members are interchangeable. Nominal typing compares identity โ the name of the type and its declared inheritance. Structural typing is more flexible and matches JavaScript’s object model; nominal typing is more precise and matches how OO languages think about types. TypeScript is structural by default and lets you simulate nominal typing with branded types when you need it.
What structural typing is
Structural typing decides assignability by comparing members.
interface Point {
x: number;
y: number;
}
interface Coordinate {
x: number;
y: number;
}
const p: Point = { x: 1, y: 2 };
const c: Coordinate = p; // โ
structurally compatible
Point and Coordinate have the same members. TypeScript treats them as interchangeable, even though they’re different named types.
The rule: A value is assignable to a type if it has at least the members that type requires, with compatible types for each.
interface HasId {
id: number;
}
interface User {
id: number;
name: string;
email: string;
}
const user: User = { id: 1, name: 'Alice', email: 'a@b.c' };
const hasId: HasId = user; // โ
User has id
User has more members than HasId. That’s fine โ extra members don’t hurt.
What structural typing checks:
| Check | Rule |
|---|---|
| Required members | Must be present |
| Member types | Must be compatible |
| Optional members | May be absent |
| Extra members | Ignored (except object literals) |
What it ignores:
- The name of the type
- Where the type was declared
- Whether it explicitly
implementsanything - Whether it’s a class, interface, or type alias
Why “structural”: It compares structure โ the shape. Two types with the same shape are the same type as far as assignability goes. Names are for humans; shapes are for the compiler.
Why TypeScript chose it: JavaScript’s object model is structural. Objects are bags of properties; you don’t declare that an object “is” a Point before using it. TypeScript mirrors that: any object with the right members works.
Why structural typing is natural in JavaScript: Every JavaScript library passes objects around by shape. A function that reads
user.namedoesn’t care what class createduser. Structural typing captures that reality. Nominal typing would require every library to declare interfaces and every consumer toimplementsthem โ impossible in the dynamic JavaScript world.
What nominal typing is
Nominal typing decides assignability by the name of the type.
// Hypothetical nominal TypeScript
class Dog {
name: string = '';
}
class Cat {
name: string = '';
}
const d: Dog = new Cat(); // โ not assignable โ Cat is not Dog
In a nominal system, Cat isn’t assignable to Dog even though they have the same members. The name differs, so the types differ.
How it works:
- Each type has a unique identity
- Assignability follows explicit
extendsorimplementsrelationships - Same shape with different names โ incompatible
Languages that use nominal typing:
| Language | System |
|---|---|
| Java | Nominal |
| C# | Nominal |
| C++ | Nominal |
| Rust | Nominal |
| Swift | Nominal |
| Kotlin | Nominal |
| TypeScript | Structural |
| Go | Structural (with interfaces) |
| Python | Structural (duck typing) |
What nominal typing prevents:
- Passing a
Meterwhere aSecondis expected (both are numbers, but different semantics) - Passing a
UserIdwhere aPostIdis expected (both are strings) - Confusing types with the same shape but different meaning
What it costs:
- Every consumer must
implementsthe interface - Wrapping a third-party class requires an explicit adapter
- Extra boilerplate to satisfy the type system
Why TypeScript avoids nominal by default: JavaScript doesn’t work that way. Importing a library and passing an object with the right properties โ that’s the norm. Nominal typing would break every pattern. So TypeScript is structural.
Why “nominal”: From the Latin nomen โ name. The name of the type is what matters. Two types with different names are different, period. Structural typing ignores the name and compares the structure.
Structural typing in action
Some examples show what structural typing allows.
Different names, same shape:
interface A {
value: number;
}
interface B {
value: number;
}
const a: A = { value: 1 };
const b: B = a; // โ
Class instances:
class Point {
constructor(public x: number, public y: number) {}
}
interface Coord {
x: number;
y: number;
}
const p = new Point(1, 2);
const c: Coord = p; // โ
โ Point has x and y
A class instance is assignable to any interface it structurally matches.
Functions:
type Add = (a: number, b: number) => number;
type Sum = (a: number, b: number) => number;
const add: Add = (a, b) => a + b;
const sum: Sum = add; // โ
Two function types with the same signature are assignable.
Objects with extra members:
interface Base {
id: number;
}
interface Extended {
id: number;
name: string;
}
const ext: Extended = { id: 1, name: 'Alice' };
const base: Base = ext; // โ
โ extra members ignored
A value with more members is assignable to a type with fewer.
Arrays and tuples:
const arr: [string, number] = ['a', 1];
const pair: [string, number] = arr; // โ
// Arrays are structurally compatible with other array shapes
const asArr: (string | number)[] = arr; // โ
Why these examples matter: Each shows structural typing doing what it promises โ comparing shapes. The names don’t matter; the members do.
What structural typing doesn’t check: It doesn’t verify that the values were meant to be that type. A Meter and Second both being numbers are interchangeable if both are just number. There’s no semantic distinction. That’s what brands are for.
Why extra members don’t break assignability: The type system assumes the consumer only uses the declared members. If
Baseonly requiresid, any object with anidworks โ extra members are irrelevant. That’s how structural typing handles extensibility.
Where structural and nominal diverge
The two systems give different answers in specific cases.
Same shape, different semantics:
type UserId = string;
type PostId = string;
function getUser(id: UserId): void { }
function getPost(id: PostId): void { }
const userId: UserId = 'u-1';
getPost(userId); // โ
structural โ both are string
Nominal version:
// In Java or C#
class UserId { }
class PostId { }
// getId(new UserId()) โ can't pass PostId
Why this matters: A UserId passed where a PostId is expected is a real bug. Structural typing allows it. Nominal typing rejects it.
The fix in TypeScript โ branding:
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
function getUser(id: UserId): void { }
function getPost(id: PostId): void { }
const userId = 'u-1' as UserId;
getPost(userId); // โ brands differ
The brand adds a phantom member, making the shapes distinct. That’s structural nominal typing โ structural under the hood, nominal in effect.
Objects with a required member vs an optional one:
interface Required {
id: number;
}
interface Optional {
id?: number;
}
const r: Required = { id: 1 };
const o: Optional = r; // โ
โ Required has the id
// const r2: Required = { }; // โ โ missing id
// const o2: Optional = {}; // โ
โ optional
Required is assignable to Optional, but not vice versa. Structural checks handle optionality.
Classes with private members: Structural typing breaks down. Private members are nominal โ the class they were declared in matters.
class A {
private secret = 'a';
}
class B {
private secret = 'b';
}
const a = new A();
// const b: B = a; // โ โ private members differ
Two classes with the same private members aren’t assignable to each other. The private member’s class is part of the type. That’s a nominal island in TypeScript’s structural sea.
Why private members behave nominally: Private members are only accessible from within the class they’re declared in. If A and B were interchangeable, B‘s methods could access A‘s private state โ a violation. So TypeScript makes them nominal.
Classes with protected members: Same behavior โ nominal.
The instanceof operator: Also nominal โ checks the prototype chain, not the shape.
class Point { x = 0; y = 0; }
const p = { x: 1, y: 2 };
p instanceof Point; // false โ plain object, not Point instance
instanceof uses the runtime class, which nominal typing would track. Structural typing doesn’t.
Where they diverge in one table:
| Case | Structural | Nominal |
|---|---|---|
| Same shape, different names | Compatible | Incompatible |
| Class instance โ matching interface | Compatible | Incompatible |
| Private members differ | Incompatible | Incompatible |
instanceof check | N/A | Runtime |
| Extra members | Ignored | Extra required |
Why private members are nominal: Private is a real access restriction. If the compiler treated two classes with identical private members as interchangeable, it would break encapsulation. Making private members nominal preserves the access rules. It’s a deliberate exception to structural typing.
Simulating nominal typing with brands
When you need nominal behavior, TypeScript lets you simulate it.
The brand pattern:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
type Email = Brand<string, 'Email'>;
Each branded type is nominally distinct โ even though they’re all string under the hood.
Creating a branded value:
function asUserId(s: string): UserId {
return s as UserId;
}
function parseEmail(s: string): Email {
if (!s.includes('@')) throw new Error('Invalid email');
return s as Email;
}
The cast is a promise โ “I’ve verified this value.” Once branded, it can’t accidentally be used as another type.
Using branded types:
function getUser(id: UserId): void { }
function getPost(id: PostId): void { }
const uid = asUserId('u-1');
getUser(uid); // โ
// getPost(uid); // โ wrong brand
The compiler catches mix-ups. The brands act like nominal types.
Opaque types: A variant of branding where the underlying type is hidden.
interface Opaque<T, B> {
readonly __brand: B;
readonly __value: T;
}
type Celsius = Opaque<number, 'Celsius'>;
type Fahrenheit = Opaque<number, 'Fahrenheit'>;
The structure is different โ __value carries the value. Consumers can’t access it without a helper.
Why brands work: They add a phantom member that nothing else has. Structural typing sees the difference in shape and refuses to interchange.
When to brand:
- IDs โ
UserId,PostId,OrderId - Units โ
Meters,Seconds,Celsius - Validated strings โ
Email,Url,Uuid - Currencies โ
USD,EUR
When not to brand:
- Where a mix-up is unlikely
- Where the value is internal to a function
- Where the brand would just add ceremony
The cost of brands:
- Every creation needs a cast
- Every import must bring the brand type
- The brand can be bypassed with
as - Libraries must agree on the same brand
Why brands aren’t a full solution: They’re a simulation, not a built-in feature. The language doesn’t enforce them โ you can always cast past. They’re a convention the team agrees on, backed by the type system.
Why branded types are the idiomatic workaround: TypeScript’s designers chose structural typing for compatibility with JavaScript. Brands give you nominal-like behavior when you need it, without changing the default. It’s the best of both โ structural for library interop, nominal for domain-specific distinctions.
Classes and interfaces under structural typing
Classes work differently in a structural system.
A class instance matches any compatible interface:
class User {
constructor(
public id: number,
public name: string
) {}
greet(): string { return `Hi, ${this.name}`; }
}
interface HasIdAndName {
id: number;
name: string;
}
const u = new User(1, 'Alice');
const h: HasIdAndName = u; // โ
โ User has id and name
No implements needed. The shape matches.
Classes aren’t distinguishable by shape:
class Cat {
name = '';
}
class Dog {
name = '';
}
const cat: Cat = new Dog(); // โ
โ same shape
Under structural typing, Dog and Cat are interchangeable if they have the same members. Nominal typing would reject this.
The private/protected exception:
class Cat {
private species = 'cat';
name = '';
}
class Dog {
private species = 'dog';
name = '';
}
const cat: Cat = new Dog(); // โ โ private members differ
Private members make the classes nominally distinct. That’s how TypeScript preserves encapsulation.
implements is documentation: It doesn’t change the type relationship. class User implements HasId is the same as class User with matching members โ the implements clause is checked but doesn’t create the relationship.
interface HasId {
id: number;
}
class User {
id = 0;
// Same as: class User implements HasId
}
const u: HasId = new User(); // โ
either way
Why implements still matters: It’s a check. If User stops matching HasId, the implements clause errors. Without it, you’d only discover the mismatch when a value was assigned.
Interfaces don’t exist at runtime: Structural typing is compile-time only. At runtime, there’s no check that an object matches an interface. TypeScript erases interfaces; the compiler verified the assignment but nothing verifies at runtime.
Why this is important for library design: A library can accept any object with the right shape โ no wrapper, no adapter. That’s the flexibility structural typing provides. But the safety is compile-time; runtime crashes are still possible if the value was any or cast.
Why
implementsdoesn’t change assignability: Structural typing is about shape. If the shape matches, the types are compatible โ with or withoutimplements. The clause is a hint to the compiler to check the shape at the class definition, not at every assignment. It catches errors earlier.
A full example
A system that uses structural typing and brands where it helps.
// ============================================
// STRUCTURAL โ SHAPE IS ENOUGH
// ============================================
interface Identifiable {
id: number;
}
interface Named {
name: string;
}
interface Person {
id: number;
name: string;
email: string;
}
// Person matches both interfaces
const person: Person = {
id: 1,
name: 'Alice',
email: 'alice@example.com'
};
const ident: Identifiable = person; // โ
structurally compatible
const named: Named = person; // โ
function logId(item: Identifiable): void {
console.log('ID:', item.id);
}
function logName(item: Named): void {
console.log('Name:', item.name);
}
logId(person); // โ
logName(person); // โ
// ============================================
// CLASSES โ STRUCTURAL UNLESS PRIVATE
// ============================================
class Point {
constructor(public x: number, public y: number) {}
}
class Vector {
constructor(public x: number, public y: number) {}
}
// Point and Vector are structurally identical
const v: Vector = new Point(1, 2); // โ
// With private members โ nominal behavior
class Secret1 {
private code = 's1';
data = '';
}
class Secret2 {
private code = 's2';
data = '';
}
const s1 = new Secret1();
// const s2: Secret2 = s1; // โ โ private members make them nominal
// ============================================
// BRANDS โ SIMULATED NOMINAL TYPING
// ============================================
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<number, 'UserId'>;
type PostId = Brand<number, 'PostId'>;
function asUserId(n: number): UserId {
return n as UserId;
}
function asPostId(n: number): PostId {
return n as PostId;
}
function findUser(id: UserId): Person {
return { id, name: 'Alice', email: 'a@b.c' };
}
function findPost(id: PostId): { id: number; title: string } {
return { id, title: 'Post' };
}
const uid = asUserId(1);
const pid = asPostId(2);
findUser(uid); // โ
findPost(pid); // โ
// findUser(pid); // โ wrong brand
// findUser(1); // โ raw number
// ============================================
// THREE-ID CHAIN
// ============================================
type OrderId = Brand<number, 'OrderId'>;
function asOrderId(n: number): OrderId {
return n as OrderId;
}
function processOrder(id: OrderId): void { }
const oid = asOrderId(3);
processOrder(oid); // โ
// processOrder(uid); // โ
// ============================================
// STRUCTURAL COMPOSITION WITH BRANDS
// ============================================
interface Entity<T> {
id: T;
createdAt: Date;
}
type UserEntity = Entity<UserId> & {
name: string;
email: string;
};
const userEntity: UserEntity = {
id: uid,
createdAt: new Date(),
name: 'Alice',
email: 'alice@example.com'
};
console.log(userEntity.id, userEntity.name);
What this shows:
- Structural compatibility โ
Personmatches bothIdentifiableandNamedwithoutimplements - Class structural matching โ
Pointassignable toVector - Private members are nominal โ
Secret1andSecret2are incompatible - Brands for IDs โ
UserId,PostId,OrderIdare distinct - Composing brands โ
Entity<UserId>uses the brand in a generic - The compiler catches mix-ups โ passing
PostIdwhereUserIdis expected fails
Structural typing handles the generic case; brands handle the domain-specific distinctions.
Why this shape: It’s how real domain models are built in TypeScript. Structural typing for interop and flexibility; brands for IDs and validated values. The two coexist โ you use brands where the distinction matters and plain structural types everywhere else.
Complete Example Session
# ============================================
# PART 1: STRUCTURAL COMPATIBILITY
# ============================================
cat > structural.ts << 'EOF'
interface A { value: number; }
interface B { value: number; }
const a: A = { value: 1 };
const b: B = a; // โ
interface C { value: number; extra: string; }
const c: C = { value: 1, extra: 'x' };
const a2: A = c; // โ
โ extra members ignored
console.log(a, b, a2);
EOF
npx tsc --noEmit structural.ts
# (no errors)
# ============================================
# PART 2: CLASS TO INTERFACE
# ============================================
cat > class.ts << 'EOF'
class User {
constructor(public id: number, public name: string) {}
}
interface HasId { id: number; }
const u = new User(1, 'Alice');
const h: HasId = u; // โ
console.log(h);
EOF
npx tsc --noEmit class.ts
# (no errors)
# ============================================
# PART 3: PRIVATE MEMBERS ARE NOMINAL
# ============================================
cat > private-members.ts << 'EOF'
class A {
private secret = 'a';
value = 1;
}
class B {
private secret = 'b';
value = 1;
}
const a = new A();
// const b: B = a; // โ
console.log(a);
EOF
npx tsc --noEmit private-members.ts
# (no errors)
# ============================================
# PART 4: BRANDED TYPES
# ============================================
cat > brands.ts << 'EOF'
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<number, 'UserId'>;
type PostId = Brand<number, 'PostId'>;
function asUserId(n: number): UserId { return n as UserId; }
function asPostId(n: number): PostId { return n as PostId; }
function findUser(id: UserId): void { console.log('user', id); }
function findPost(id: PostId): void { console.log('post', id); }
const uid = asUserId(1);
const pid = asPostId(2);
findUser(uid); // โ
findPost(pid); // โ
// findUser(pid); // โ
// findUser(1); // โ
console.log(uid, pid);
EOF
npx tsc --noEmit brands.ts
# (no errors)
# ============================================
# PART 5: SATISFIES FOR SHAPE CHECKS
# ============================================
cat > satisfies.ts << 'EOF'
interface Config {
host: string;
port: number;
}
const config = {
host: 'localhost',
port: 8080,
ssl: true
} satisfies Config;
// config.ssl still accessible
console.log(config.ssl);
// Without satisfies โ widened type
const loose: Config = {
host: 'localhost',
port: 8080
// ssl: true // โ excess property
};
console.log(loose);
EOF
npx tsc --noEmit satisfies.ts
# (no errors)
# ============================================
# PART 6: UNITS AS BRANDS
# ============================================
cat > units.ts << 'EOF'
type Brand<T, B extends string> = T & { readonly __brand: B };
type Meters = Brand<number, 'Meters'>;
type Feet = Brand<number, 'Feet'>;
function asMeters(n: number): Meters { return n as Meters; }
function asFeet(n: number): Feet { return n as Feet; }
function formatDistance(d: Meters): string {
return `${d} m`;
}
const m = asMeters(10);
const f = asFeet(30);
console.log(formatDistance(m));
// console.log(formatDistance(f)); // โ wrong unit
// console.log(formatDistance(5)); // โ raw number
EOF
npx tsc --noEmit units.ts
# (no errors)
# ============================================
# PART 7: OPAQUE TYPES
# ============================================
cat > opaque.ts << 'EOF'
interface Opaque<T, B> {
readonly __brand: B;
readonly __value: T;
}
type Celsius = Opaque<number, 'Celsius'>;
type Fahrenheit = Opaque<number, 'Fahrenheit'>;
function celsius(n: number): Celsius {
return { __value: n } as Celsius;
}
function toF(c: Celsius): Fahrenheit {
const f = (c.__value as number) * 9 / 5 + 32;
return { __value: f } as Fahrenheit;
}
const c = celsius(100);
const f = toF(c);
console.log(c, f);
EOF
npx tsc --noEmit opaque.ts
# (no errors)
# ============================================
# PART 8: COMPILE AND RUN
# ============================================
npx tsc structural.ts class.ts private-members.ts brands.ts satisfies.ts units.ts opaque.ts
node structural.js
# [ { value: 1 } { value: 1 } { value: 1, extra: 'x' } ]
node class.js
# [ User { id: 1, name: 'Alice' } ]
node private-members.js
# [ A { secret: 'a', value: 1 } ]
node brands.js
# [ 1 2 ]
# [ user 1 ]
# [ post 2 ]
node satisfies.js
# [ true ]
# [ { host: 'localhost', port: 8080 } ]
node units.js
# [ 10 m ]
node opaque.js
# [ { __value: 100 } { __value: 212 } ]
Quick Reference
Structural vs Nominal
| Aspect | Structural | Nominal |
|---|---|---|
| Compares | Shape | Name |
| Same shape, different names | โ Compatible | โ Incompatible |
| Extra members | Ignored | Varies |
| Explicit implements | Optional | Required |
| JavaScript fit | โ Natural | โ Awkward |
| Semantic safety | Weak | Strong |
| TypeScript default | โ | โ |
Where TypeScript Is Nominal
| Feature | Behavior |
|---|---|
| Private members | Nominal |
| Protected members | Nominal |
instanceof | Nominal (runtime) |
#private fields | Nominal |
| Class identity (partly) | Nominal |
Where TypeScript Is Structural
| Feature | Behavior |
|---|---|
| Interfaces | Structural |
| Type aliases | Structural |
| Object literals | Structural |
| Functions | Structural |
| Classes without private | Structural |
Assignability Rules
| Check | Rule |
|---|---|
| Required members | Must be present |
| Member types | Must be compatible |
| Optional members | May be absent |
| Extra members | Ignored (except object literals) |
| Private members | Must come from the same class |
Branding
| Aspect | Detail |
|---|---|
| Syntax | T & { readonly __brand: B } |
| Runtime | Phantom (no value) |
| Effect | Nominal-like |
| Creation | Cast from raw value |
| Bypass | Possible with as |
| Use for | IDs, units, validated strings |
Opaque Types
| Form | Meaning |
|---|---|
interface Opaque<T, B> { readonly __brand: B; readonly __value: T } | Hide the value |
| Access | Via helper functions |
| Advantage | Value inaccessible without cast |
| Disadvantage | More boilerplate |
Common Branded Types
| Type | Underlying |
|---|---|
UserId | string or number |
Email | string |
Url | string |
Uuid | string |
Meters | number |
Celsius | number |
USD | number |
Class Assignability
| Case | Assignable |
|---|---|
| Same shape | โ |
| Class โ matching interface | โ |
| Two classes, same shape | โ |
| Two classes with different private | โ |
| Subclass โ superclass | โ |
| Superclass โ subclass | โ |
Practical Differences
| Scenario | Structural | Nominal |
|---|---|---|
| Two IDs of the same primitive | Interchangeable | Distinct |
| Third-party class โ interface | Works | Needs implements |
| Wrapping a library type | Works | Needs adapter |
| Distinguishing types | Weak | Strong |
| Refactoring | Rename-safe | Name-dependent |
When to Brand
| Situation | Brand? |
|---|---|
| IDs | โ |
| Units | โ |
| Validated strings | โ |
| Currencies | โ |
| Internal values | โ |
| Single-use types | โ |
| Hot paths (perf) | โ ๏ธ |
Error Messages
| Error | Meaning |
|---|---|
Property missing | Structural mismatch |
not assignable | Shape differs |
Private member from different class | Nominal check |
Type X not assignable to Y | Brand mismatch |
Brand Helpers
| Helper | Purpose |
|---|---|
Brand<T, B> | Add a brand |
asUserId(s) | Cast to brand |
parseEmail(s) | Validate and cast |
isBranded(v) | Check at runtime (rare) |
Structural Typing Rules
| Value | Assignable to type |
|---|---|
| Same shape | โ |
| More members | โ |
| Fewer members | โ |
| Compatible types | โ |
| Incompatible types | โ |
| Different private | โ |
Best Practices
โ Do This:
// Rely on structural typing for general-purpose code
function process(item: { id: number }): void { } // โ
// Use interfaces for shape contracts
interface Identifiable { id: number; } // โ
// Use `implements` as documentation
class User implements Identifiable {
id = 1;
} // โ
// Brand IDs to distinguish them
type UserId = Brand<number, 'UserId'>; // โ
// Validate before casting
function parseEmail(s: string): Email {
if (!s.includes('@')) throw new Error('Invalid');
return s as Email;
} // โ
// Use brands for units
type Meters = Brand<number, 'Meters'>; // โ
// Combine structural and nominal where each fits
interface Entity<T> { id: T; createdAt: Date; } // โ
// Understand private members as nominal
// No cross-class assignment with private // โ
// Use satisfies for shape checks without widening
const config = { host: '', port: 0 } satisfies Config; // โ
โ Don’t Do This:
// Don't assume structural typing catches semantic mistakes
function getUser(id: number): void { }
function getPost(id: number): void { }
getUser(1); // โ ๏ธ no distinction // โ ๏ธ
// Don't rely on class names to distinguish
class User { name = ''; }
class Admin { name = ''; }
const a: User = new Admin(); // โ ๏ธ same shape // โ ๏ธ
// Don't use brands for trivial types
type String1 = Brand<string, 'String1'>; // โ ๏ธ overkill // โ ๏ธ
// Don't bypass brands with `as` casually
const id = 'x' as UserId; // โ ๏ธ no validation // โ ๏ธ
// Don't brand what the domain doesn't distinguish
type Color = Brand<string, 'Color'>; // โ ๏ธ if only one color exists // โ ๏ธ
// Don't expect runtime checks from types
if (typeof user === 'User') { } // โ ๏ธ interfaces don't exist at runtime // โ ๏ธ
// Don't confuse `implements` with a type relationship
// It's a check, not a declaration of compatibility // โ ๏ธ
// Don't fight the structural model
// Work with it โ brands, discriminated unions โ not against it // โ ๏ธ
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Same-shape confusion | Semantic bugs | Use brands |
| Private member surprise | Nominal mismatch | Understand the rule |
implements assumed relational | Not how it works | Shape is what matters |
instanceof on interfaces | Runtime error | Use predicates |
| Brand overuse | Boilerplate | Brand only where needed |
| Casting past brands | Loses safety | Validate before casting |
| Expecting runtime checks | Types erased | Validate at boundaries |
| Same-shaped classes | Silent bugs | Use discriminated unions |
Real-World Examples
1. Same shape, compatible
interface A { x: number; }
interface B { x: number; }
const b: B = { x: 1 } as A; // โ
2. Class to interface
class User { id = 1; }
const u: { id: number } = new User(); // โ
3. Private members are nominal
class A { private p = 1; }
class B { private p = 2; }
// const b: B = new A(); // โ
4. Brand for ID
type UserId = number & { readonly __brand: 'UserId' };
5. Brand with helper
type Brand<T, B> = T & { readonly __brand: B };
type PostId = Brand<number, 'PostId'>;
6. Validate and cast
function parseEmail(s: string): Email {
if (!s.includes('@')) throw new Error();
return s as Email;
}
7. Units with brands
type Meters = Brand<number, 'Meters'>;
type Feet = Brand<number, 'Feet'>;
8. Opaque type
interface Opaque<T, B> {
readonly __brand: B;
readonly __value: T;
}
9. Satisfies
const config = { host: 'x', port: 80 } satisfies Config;
10. Structural function types
type Fn = (a: number) => string;
const f: Fn = (a: number) => `${a}`; // โ
11. Cross-class structural match
class Point { constructor(public x: number, public y: number) {} }
class Vec { constructor(public x: number, public y: number) {} }
const v: Vec = new Point(1, 2); // โ
12. Discriminated union over classes
type Shape =
| { kind: 'circle'; r: number }
| { kind: 'square'; s: number };
13. Nominal-like via discriminant
type UserId = { readonly type: 'UserId'; value: number };
type PostId = { readonly type: 'PostId'; value: number };
14. Structural predicate
function isUser(x: unknown): x is User {
return typeof x === 'object' && x !== null && 'id' in x;
}
15. Same-shaped classes caught
class Cat { kind = 'cat' as const; }
class Dog { kind = 'dog' as const; }
type Pet = Cat | Dog;
// Distinguish by discriminant, not shape
16. Interfaces erasing at runtime
interface User { id: number; }
// No runtime check for User
17. Branded and structural
interface Entity<T> { id: T; name: string; }
type UserEntity = Entity<UserId>;
18. Structural class composition
class A { a = 1; }
class B { b = 2; }
type C = A & B;
const c: C = Object.assign(new A(), new B());
19. Compile-time only
type Email = Brand<string, 'Email'>;
// At runtime, just a string
20. Private preserves encapsulation
class Bank {
#balance = 0;
deposit(n: number) { this.#balance += n; }
}
// No cross-class compat with #balance
Visual: Structural Typing
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ interface Point { x: number; y: number } โ
โ interface Coord { x: number; y: number } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ const p: Point = { x: 1, y: 2 }; โ
โ const c: Coord = p; โ
โ
โ โ
โ Same members โ interchangeable โ
โ Names don't matter โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Nominal Typing
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ class Point { x: number; y: number; } โ
โ class Coord { x: number; y: number; } โ
โ โ
โ const c: Coord = new Point(); โ
โ โ not assignable โ
โ โ
โ Same members but different names โ
โ Names matter โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: TypeScript โ Mostly Structural
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Structural (default): โ
โ โ
โ โ Interfaces โ
โ โ Type aliases โ
โ โ Object literals โ
โ โ Functions โ
โ โ Classes without private โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Nominal (exceptions): โ
โ โ
โ โ Private members โ
โ โ Protected members โ
โ โ #private fields โ
โ โ instanceof (runtime) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Branded Types
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ string โ
โ โ any string โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ Brand<string, 'UserId'>
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ string & { readonly __brand: 'UserId' } โ
โ โ
โ A string with a phantom member โ
โ Structurally distinct from plain string โ
โ Structurally distinct from other brands โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ UserId โ PostId โ string โ
โ UserId โ string (assignable up) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Private Members Are Nominal
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ class A { โ
โ private secret = 'a'; โ
โ } โ
โ โ
โ class B { โ
โ private secret = 'b'; โ
โ } โ
โ โ
โ new A() assignable to B? โ
โ โ no โ
โ โ
โ Same shape, different private class โ
โ The private member carries its class โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Class to Interface
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ class User { โ
โ id = 0; โ
โ name = ''; โ
โ } โ
โ โ
โ interface HasId { id: number; } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ const u = new User(); โ
โ const h: HasId = u; โ
โ
โ โ
โ No implements needed โ
โ Shape matches โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Simulating Nominal with Brands
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Without brands: โ
โ โ
โ type UserId = number; โ
โ type PostId = number; โ
โ โ
โ const uid: UserId = 1; โ
โ const pid: PostId = uid; โ
โ
โ โ ๏ธ no distinction โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ With brands: โ
โ โ
โ type UserId = number & { __brand: 'UserId' };โ
โ type PostId = number & { __brand: 'PostId' };โ
โ โ
โ const uid = 1 as UserId; โ
โ const pid: PostId = uid; โ โ
โ โ
distinction enforced โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Opaque Types
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ interface Opaque<T, B> { โ
โ readonly __brand: B; โ
โ readonly __value: T; โ
โ } โ
โ โ
โ type Celsius = Opaque<number, 'Celsius'>; โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ { __brand: ..., __value: 100 } โ โ
โ โ โ โ
โ โ Value inaccessible without cast โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Structural vs Nominal Summary
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Structural typing: โ
โ โ
โ Same shape โ interchangeable โ
โ Flexible, natural in JS โ
โ Weaker semantics โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Nominal typing: โ
โ โ
โ Same name โ interchangeable โ
โ Strict, more boilerplate โ
โ Stronger semantics โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ TypeScript: โ
โ โ
โ Structural by default โ
โ Nominal for private/protected โ
โ Brands simulate nominal โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Decision Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Two types with the same shape? โ
โ โ โ
โ โโโ Need them interchangeable? โ
โ โ โโโ Structural (default) โ
โ โ โ
โ โโโ Need them distinct? โ
โ โโโ Brand or discriminant โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Working with classes? โ
โ โ โ
โ โโโ No private/protected โโโบ structuralโ
โ โ โ
โ โโโ With private/protected โโโบ nominal โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Two IDs of the same type? โ
โ โ โ
โ โโโ Brand them โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Interop with a library? โ
โ โ โ
โ โโโ Rely on structural typing โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Concept | Meaning |
|---|---|
| Structural typing | Compare shapes โ same members, same type |
| Nominal typing | Compare names โ same name, same type |
| TypeScript default | Structural |
| Nominal exceptions | Private / protected members, instanceof |
| Brands | Phantom member that makes a type nominally distinct |
| Opaque types | Brand with a hidden value |
implements | A check, not a type relationship |
satisfies | Shape check without widening |
Key takeaways:
- Structural typing compares shapes โ same members, interchangeable
- Nominal typing compares names โ same name, interchangeable
- TypeScript is structural by default โ matches JavaScript’s object model
- Interfaces are structural โ any matching object works
- Class instances are structural unless they have private/protected members
- Private members are nominal โ classes with them aren’t cross-assignable
implementsis a documentation check, not a type relationship- Brands โ
T & { readonly __brand: B }โ simulate nominal typing - Use brands for IDs, units, validated strings, currencies
- Opaque types hide the underlying value
instanceofis nominal (runtime prototype check)- Structural typing is more flexible; nominal typing is more precise
- Combine both โ structural for interop, brands for domain distinctions
Remember: TypeScript chose structural typing because JavaScript is structural. Every library passes objects by shape; forcing nominal declarations would break the ecosystem. But structural typing has a weakness โ same-shaped types are interchangeable, even when they mean different things. Brands fix that. The combination โ structural defaults, nominal exceptions for private members, brands for the rest โ gives you the flexibility of JavaScript with the precision of a nominal system where it matters.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!