| |

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:

CheckRule
Required membersMust be present
Member typesMust be compatible
Optional membersMay be absent
Extra membersIgnored (except object literals)

What it ignores:

  • The name of the type
  • Where the type was declared
  • Whether it explicitly implements anything
  • 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.name doesn’t care what class created user. Structural typing captures that reality. Nominal typing would require every library to declare interfaces and every consumer to implements them โ€” 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 extends or implements relationships
  • Same shape with different names โ†’ incompatible

Languages that use nominal typing:

LanguageSystem
JavaNominal
C#Nominal
C++Nominal
RustNominal
SwiftNominal
KotlinNominal
TypeScriptStructural
GoStructural (with interfaces)
PythonStructural (duck typing)

What nominal typing prevents:

  • Passing a Meter where a Second is expected (both are numbers, but different semantics)
  • Passing a UserId where a PostId is expected (both are strings)
  • Confusing types with the same shape but different meaning

What it costs:

  • Every consumer must implements the 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 Base only requires id, any object with an id works โ€” 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:

CaseStructuralNominal
Same shape, different namesCompatibleIncompatible
Class instance โ†’ matching interfaceCompatibleIncompatible
Private members differIncompatibleIncompatible
instanceof checkN/ARuntime
Extra membersIgnoredExtra 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 implements doesn’t change assignability: Structural typing is about shape. If the shape matches, the types are compatible โ€” with or without implements. 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 โ€” Person matches both Identifiable and Named without implements
  • Class structural matching โ€” Point assignable to Vector
  • Private members are nominal โ€” Secret1 and Secret2 are incompatible
  • Brands for IDs โ€” UserId, PostId, OrderId are distinct
  • Composing brands โ€” Entity<UserId> uses the brand in a generic
  • The compiler catches mix-ups โ€” passing PostId where UserId is 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

AspectStructuralNominal
ComparesShapeName
Same shape, different namesโœ… CompatibleโŒ Incompatible
Extra membersIgnoredVaries
Explicit implementsOptionalRequired
JavaScript fitโœ… NaturalโŒ Awkward
Semantic safetyWeakStrong
TypeScript defaultโœ…โŒ

Where TypeScript Is Nominal

FeatureBehavior
Private membersNominal
Protected membersNominal
instanceofNominal (runtime)
#private fieldsNominal
Class identity (partly)Nominal

Where TypeScript Is Structural

FeatureBehavior
InterfacesStructural
Type aliasesStructural
Object literalsStructural
FunctionsStructural
Classes without privateStructural

Assignability Rules

CheckRule
Required membersMust be present
Member typesMust be compatible
Optional membersMay be absent
Extra membersIgnored (except object literals)
Private membersMust come from the same class

Branding

AspectDetail
SyntaxT & { readonly __brand: B }
RuntimePhantom (no value)
EffectNominal-like
CreationCast from raw value
BypassPossible with as
Use forIDs, units, validated strings

Opaque Types

FormMeaning
interface Opaque<T, B> { readonly __brand: B; readonly __value: T }Hide the value
AccessVia helper functions
AdvantageValue inaccessible without cast
DisadvantageMore boilerplate

Common Branded Types

TypeUnderlying
UserIdstring or number
Emailstring
Urlstring
Uuidstring
Metersnumber
Celsiusnumber
USDnumber

Class Assignability

CaseAssignable
Same shapeโœ…
Class โ†’ matching interfaceโœ…
Two classes, same shapeโœ…
Two classes with different privateโŒ
Subclass โ†’ superclassโœ…
Superclass โ†’ subclassโŒ

Practical Differences

ScenarioStructuralNominal
Two IDs of the same primitiveInterchangeableDistinct
Third-party class โ†’ interfaceWorksNeeds implements
Wrapping a library typeWorksNeeds adapter
Distinguishing typesWeakStrong
RefactoringRename-safeName-dependent

When to Brand

SituationBrand?
IDsโœ…
Unitsโœ…
Validated stringsโœ…
Currenciesโœ…
Internal valuesโŒ
Single-use typesโŒ
Hot paths (perf)โš ๏ธ

Error Messages

ErrorMeaning
Property missingStructural mismatch
not assignableShape differs
Private member from different classNominal check
Type X not assignable to YBrand mismatch

Brand Helpers

HelperPurpose
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

ValueAssignable 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

PitfallProblemSolution
Same-shape confusionSemantic bugsUse brands
Private member surpriseNominal mismatchUnderstand the rule
implements assumed relationalNot how it worksShape is what matters
instanceof on interfacesRuntime errorUse predicates
Brand overuseBoilerplateBrand only where needed
Casting past brandsLoses safetyValidate before casting
Expecting runtime checksTypes erasedValidate at boundaries
Same-shaped classesSilent bugsUse 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

ConceptMeaning
Structural typingCompare shapes โ€” same members, same type
Nominal typingCompare names โ€” same name, same type
TypeScript defaultStructural
Nominal exceptionsPrivate / protected members, instanceof
BrandsPhantom member that makes a type nominally distinct
Opaque typesBrand with a hidden value
implementsA check, not a type relationship
satisfiesShape 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
  • implements is 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
  • instanceof is 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!