Siddhant Deval
Siddhant Deval
system design16 min read

Structural Typing, Nominal Modeling & Branded Primitives

TypeScript's structural type system treats identically shaped objects as interchangeable — a silent footgun for domain models where a UserId and an AccountId share the same underlying string type. This article teaches the Brand pattern, discriminated unions, and polymorphic `this` typing to build zero-runtime-cost nominal domain boundaries.

Series·Part 2 of 13

TypeScript Low-Level Design & Object-Oriented Architecture

Structural Typing, Nominal Modeling & Branded Primitives

In TypeScript 5+, OOP is an architectural contract, not an inheritance tree: enforce domain invariants at compile time, encapsulate mutation strictly within aggregates, and invert dependencies so high-level business policy never couples to execution details. The contract begins at the primitive level — and TypeScript's structural type system creates a silent hazard at exactly that boundary.

In every nominal OOP language — Java, C#, Swift — two classes are different types by definition, regardless of how similar their fields look. In TypeScript, the opposite is true. Two classes with the same public shape are interchangeable. This is not a bug; it is a deliberate design choice that enables duck-typed composition and structural subtyping. But in a fintech domain where a CustomerId and a VendorId are both string, it opens a category of bugs that the compiler will never catch without explicit intervention.


1. The Anti-Pattern Graveyard: The Accidental Currency Mix-Up

Here is a bug that has caused real production financial incidents. It compiles without error, passes linting, and deploys to production:

TYPESCRIPT
// ❌ Anti-Pattern: Unbranded monetary primitives — the compiler cannot distinguish currencies

function addBalances(a: number, b: number): number {
  return a + b;
}

const usdBalance = 1000;   // USD in cents
const eurBalance = 850;    // EUR in cents

// This compiles cleanly — both are just `number`
const total = addBalances(usdBalance, eurBalance); // 1850 — wrong! Mixed currencies

The function addBalances accepts any two numbers. There is no mechanism in the type system — without branding — to distinguish a USD balance from a EUR balance from a BTC satoshi count. All three are number.

This is the category of bug that nominal branding eliminates at compile time, not runtime. The fix costs zero bytes of additional JavaScript.


2. Understanding Structural vs. Nominal Typing

2.1 How TypeScript's Structural System Works

TypeScript uses structural compatibility, not nominal identity. Two types are compatible if their shapes match — the names of the types are irrelevant to the comparison.

TYPESCRIPT
class CustomerAccount {
  id: string;
  balance: number;

  constructor(id: string, balance: number) {
    this.id = id;
    this.balance = balance;
  }
}

class VendorAccount {
  id: string;
  balance: number;

  constructor(id: string, balance: number) {
    this.id = id;
    this.balance = balance;
  }
}

function processCustomerAccount(account: CustomerAccount): void {
  console.log(`Processing customer: ${account.id}`);
}

const vendor = new VendorAccount('vnd_001', 5000);

// ✅ This compiles without error — VendorAccount is structurally identical to CustomerAccount
processCustomerAccount(vendor);
// Output: "Processing customer: vnd_001"
// The function ran a VendorAccount through customer-specific logic with no type error

The compiler sees that VendorAccount and CustomerAccount have the same public fields (id: string, balance: number) and concludes they are assignable. From TypeScript's perspective, they are the same type. This is structurally correct, but it is architecturally catastrophic in a domain where customer accounts and vendor accounts have different regulatory rules, audit trails, and balance semantics.

2.2 What Nominal Typing Would Give You

In a nominally-typed language, the class name itself is the identity. VendorAccount would be a distinct type from CustomerAccount at the type-system level, regardless of field similarity. TypeScript does not have this by default — but it can be emulated without any runtime cost.


3. The Brand Pattern: Zero-Cost Nominal Primitives

A brand is a phantom type tag attached to a primitive. It exists only in the TypeScript type checker. After compilation, the branded primitive is identical to its underlying type — no wrapper object, no extra bytes, no runtime indirection.

3.1 Defining the Brand Utility Type

TYPESCRIPT
// The Brand utility type — a type-level tag that creates nominal identity
declare const __brand: unique symbol;
type Brand<T, TBrand extends string> = T & { readonly [__brand]: TBrand };

// Domain-specific branded primitives
type CustomerId = Brand<string, 'CustomerId'>;
type VendorId   = Brand<string, 'VendorId'>;
type OrderId    = Brand<string, 'OrderId'>;
type UsdCents   = Brand<number, 'UsdCents'>;
type EurCents   = Brand<number, 'EurCents'>;

The unique symbol is key: it creates a property key that no two declarations can share, ensuring that Brand<string, 'CustomerId'> and Brand<string, 'VendorId'> are genuinely distinct types even though both extend string.

3.2 Creating Branded Values via Narrowing Functions

The only way to create a branded value is through an explicit casting function — a smart constructor. This is the single enforcement point for any runtime validation:

TYPESCRIPT
// Smart constructors — the only sanctioned way to produce a branded value
function asCustomerId(raw: string): CustomerId {
  if (!/^cus_[a-z0-9]{8,}$/.test(raw)) {
    throw new Error(`Invalid CustomerId: "${raw}"`);
  }
  return raw as CustomerId; // The only use of `as` — validated and contained
}

function asUsdCents(raw: number): UsdCents {
  if (!Number.isInteger(raw) || raw < 0) {
    throw new Error('UsdCents must be a non-negative integer');
  }
  return raw as UsdCents;
}

// Usage
const customerId = asCustomerId('cus_a1b2c3d4'); // CustomerId — branded
const usdBalance = asUsdCents(50000);             // UsdCents — branded

3.3 Branded Types in Action: Compile-Time Currency Safety

TYPESCRIPT
// ❌ Anti-Pattern — unbranded: the compiler cannot help
function transferUnbranded(from: number, to: number, amount: number): void {
  // Is amount USD? EUR? Satoshis? No way to tell from the type
}

// ✅ Branded — the compiler enforces correctness
function transferUsd(
  fromAccount: CustomerId,
  toAccount: CustomerId,
  amount: UsdCents,
): void {
  console.log(`Transferring ${amount} USD cents from ${fromAccount} to ${toAccount}`);
}

const customer = asCustomerId('cus_a1b2c3d4');
const recipient = asCustomerId('cus_e5f6g7h8');
const amount = asUsdCents(10000); // $100.00

// ✅ Correct call
transferUsd(customer, recipient, amount);

// ❌ These all fail at compile time — no runtime cost for the check
const vendorId = 'vnd_001' as VendorId;
transferUsd(vendorId, recipient, amount);
// Error: Argument of type 'VendorId' is not assignable to parameter of type 'CustomerId'

const eurAmount = 8500 as EurCents;
transferUsd(customer, recipient, eurAmount);
// Error: Argument of type 'EurCents' is not assignable to parameter of type 'UsdCents'

The type errors appear in the IDE before the code runs. No tests needed for this class of bug — the compiler is the test.

Two-column diagram. LEFT column labeled 'Without Branding' in red: shows a function signature 'transferUnbranded(from: number, to: number, amount: number)' in dim monospace. Below it, four arrows pointing into the function from values: '1000 (USD)', '850 (EUR)', 'cus_001 (string)', 'vnd_002 (string)'. All arrows are the same red color with label 'ALL ACCEPTED'. A red callout reads 'Compiler cannot distinguish — silent mix-up possible'. RIGHT column labeled 'With Nominal Branding' in cyan: shows a function signature 'transferUsd(from: CustomerId, to: CustomerId, amount: UsdCents)' in cyan monospace. Below it, four arrows: green arrow for 'cus_001 (CustomerId)' with label 'ACCEPTED'; red blocked arrows for 'vnd_002 (VendorId)', '850 (EurCents)', '"raw string"' each with label 'COMPILE ERROR'. A cyan callout reads 'Compiler enforces domain boundaries at zero runtime cost'.
Two-column diagram. LEFT column labeled 'Without Branding' in red: shows a function signature 'transferUnbranded(from: number, to: number, amount: number)' i…

4. Template Literal Brands: Validating ID Schemas

A common fintech pattern is structured identifier prefixes: ord_, cus_, txn_, acc_. Template literal brands enforce these schemas at the type level without a runtime string parser.

4.1 Template Literal Type as a Schema

TYPESCRIPT
// Template literal brand — enforces the "ord_" prefix at compile time
type OrderIdLiteral = `ord_${string}`;

// Usage
function fetchOrder(id: OrderIdLiteral): Promise<void> {
  return fetch(`/api/orders/${id}`).then(() => {});
}

// ✅ Correctly prefixed — accepted
fetchOrder('ord_abc123def456');

// ❌ Wrong prefix — rejected at compile time
fetchOrder('cus_abc123def456');
// Error: Argument of type '"cus_abc123def456"' is not assignable to parameter of type '`ord_${string}`'

fetchOrder('12345'); // ❌ No prefix — rejected

4.2 Combining Template Literals with Brands for Runtime + Compile-Time Safety

For the highest safety, combine both: the template literal type provides structural prefix validation, and a Brand tag prevents accidental structural assignability:

TYPESCRIPT
type TransactionId = Brand<`txn_${string}`, 'TransactionId'>;

function asTransactionId(raw: string): TransactionId {
  if (!raw.startsWith('txn_') || raw.length < 12) {
    throw new Error(`Invalid TransactionId: "${raw}"`);
  }
  return raw as TransactionId;
}

// A plain `txn_` prefixed string is NOT assignable to TransactionId without going through the constructor
const txnId = asTransactionId('txn_a1b2c3d4e5f6'); // ✅
const fakeId: TransactionId = 'txn_a1b2c3d4e5f6' as any; // Explicit bypass — intentional

5. Polymorphic this Typing: Fluent Builders That Preserve Type

In inheritance hierarchies, a base class method that returns this should return the type of the most-derived class, not the base class. TypeScript's polymorphic this type solves this automatically.

5.1 The Problem Without Polymorphic this

TYPESCRIPT
// ❌ Without polymorphic this — builder chain loses derived type
class BaseQuery {
  protected filters: string[] = [];

  addFilter(filter: string): BaseQuery { // ← Returns BaseQuery, not derived type
    this.filters.push(filter);
    return this;
  }

  build(): string {
    return this.filters.join(' AND ');
  }
}

class LedgerQuery extends BaseQuery {
  private accountId?: string;

  forAccount(id: string): LedgerQuery {
    this.accountId = id;
    return this;
  }
}

const query = new LedgerQuery()
  .addFilter('amount > 0')   // Returns BaseQuery — type information lost!
  .forAccount('acc_001');    // ❌ Error: Property 'forAccount' does not exist on type 'BaseQuery'

5.2 The Fix: Polymorphic this Return Type

TYPESCRIPT
// ✅ Polymorphic this — derived type preserved through the chain
class BaseQuery {
  protected filters: string[] = [];

  addFilter(filter: string): this { // ← Returns `this`, not `BaseQuery`
    this.filters.push(filter);
    return this;
  }

  build(): string {
    return this.filters.join(' AND ');
  }
}

class LedgerQuery extends BaseQuery {
  private accountId?: string;

  forAccount(id: string): this {
    this.accountId = id;
    return this;
  }
}

const query = new LedgerQuery()
  .addFilter('amount > 0')   // Returns LedgerQuery — type preserved ✅
  .forAccount('acc_001')     // ✅ Works correctly
  .addFilter('status = paid') // ✅ Can chain back to base methods
  .build();

console.log(query); // "amount > 0 AND status = paid"

The this return type is resolved at each call site to the most-specific type available. It enables true fluent API chains across base and derived classes without manual type casting.


6. Discriminated Unions as OOP Alternatives

Not every polymorphic type benefits from class inheritance. When variants contain only data — no shared mutable state, no shared methods with implementation logic — discriminated unions outperform class hierarchies on bundle size, serialization, and exhaustiveness guarantees.

6.1 Class Hierarchy Approach (When Not to Use It)

TYPESCRIPT
// ❌ Overkill class hierarchy for a pure data variant
abstract class PaymentEvent {
  abstract readonly kind: string;
  abstract readonly occurredAt: Date;
}

class PaymentAuthorized extends PaymentEvent {
  readonly kind = 'AUTHORIZED';
  constructor(
    readonly occurredAt: Date,
    readonly authCode: string,
    readonly amount: number,
  ) { super(); }
}

class PaymentDeclined extends PaymentEvent {
  readonly kind = 'DECLINED';
  constructor(
    readonly occurredAt: Date,
    readonly reason: string,
  ) { super(); }
}

class PaymentRefunded extends PaymentEvent {
  readonly kind = 'REFUNDED';
  constructor(
    readonly occurredAt: Date,
    readonly refundAmount: number,
    readonly originalTransactionId: string,
  ) { super(); }
}

This emits prototype chains, requires super() calls, and compiles to more JavaScript than necessary. There is no shared implementation to inherit — only data shapes.

6.2 Discriminated Union Approach (Correct Pattern)

TYPESCRIPT
// ✅ Discriminated union — zero prototype overhead, exhaustive pattern matching
type PaymentEvent =
  | { kind: 'AUTHORIZED'; occurredAt: Date; authCode: string; amount: number }
  | { kind: 'DECLINED';   occurredAt: Date; reason: string }
  | { kind: 'REFUNDED';   occurredAt: Date; refundAmount: number; originalTransactionId: string };

// Exhaustive handler — compiler guarantees all variants are covered
function describeEvent(event: PaymentEvent): string {
  switch (event.kind) {
    case 'AUTHORIZED':
      return `Authorized $${event.amount / 100} with code ${event.authCode}`;
    case 'DECLINED':
      return `Declined: ${event.reason}`;
    case 'REFUNDED':
      return `Refunded $${event.refundAmount / 100} for txn ${event.originalTransactionId}`;
    default:
      // TS ensures this branch is unreachable if all variants are handled
      assertNever(event);
  }
}

function assertNever(value: never): never {
  throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}

6.3 When to Choose Each

Dimension Discriminated Union Class Hierarchy
Runtime overhead None — plain objects Prototype chain allocation
Serialization Directly JSON-serializable Requires toJSON() or transformer
Exhaustiveness check Native — switch + assertNever Requires abstract method + override
Shared state Not applicable — stateless variants Supported — base class fields
Shared method implementation Not applicable Supported — template methods
instanceof checks Not possible Available
Recommended when Variants are pure data; exhaustive matching needed Variants share behavior or stateful methods
Comparison matrix with dark background. Three rows (Class Hierarchy, Discriminated Union) × five columns (Runtime Overhead, Serialization, Exhaustiveness, Shared Behavior, Bundle Size). Title: 'Class Hierarchy vs Discriminated Union — Decision Matrix'. Class Hierarchy row values: 'Prototype chain allocation' in amber, 'Requires toJSON()' in amber, 'Abstract method + override' in dim, 'Supported — template methods' in green, 'Higher — emits class bodies' in red. Discriminated Union row values: 'None — plain objects' in green, 'Native JSON.stringify()' in green, 'switch + assertNever — compiler-enforced' in cyan, 'Not applicable — use functions' in dim, 'Lower — no prototype emission' in green. A bottom row summary: 'Prefer discriminated unions for pure data variants; use classes when shared mutable state or inherited implementation is required.' in #a5b0bd.
Comparison matrix with dark background. Three rows (Class Hierarchy, Discriminated Union) × five columns (Runtime Overhead, Serialization, Exhaustiveness, Sh…

7. The satisfies Operator for Exhaustive Registries

TypeScript 4.9 introduced satisfies — an operator that validates a value against a type without widening the inferred type of the value. In the fintech domain, this is the ideal tool for building strategy and event handler registries that must cover every variant.

TYPESCRIPT
type SupportedCurrency = 'USD' | 'EUR' | 'GBP';

type CurrencyConfig = {
  symbol: string;
  minorUnit: number;       // Number of decimal places
  roundingMode: 'HALF_UP' | 'HALF_EVEN';
};

// satisfies ensures all three currencies are covered,
// but inferred type retains the literal values — not just 'CurrencyConfig'
const currencyRegistry = {
  USD: { symbol: '$',  minorUnit: 2, roundingMode: 'HALF_UP'  },
  EUR: { symbol: '€',  minorUnit: 2, roundingMode: 'HALF_EVEN' },
  GBP: { symbol: '£',  minorUnit: 2, roundingMode: 'HALF_UP'  },
} satisfies Record<SupportedCurrency, CurrencyConfig>;

// ✅ Literal type is preserved — roundingMode is 'HALF_UP', not the wider string
const usdRounding = currencyRegistry.USD.roundingMode; // Type: 'HALF_UP' (not 'HALF_UP' | 'HALF_EVEN')

// Adding a new currency to SupportedCurrency without updating the registry triggers a compile error:
// Property 'JPY' is missing in type... satisfies Record<SupportedCurrency, CurrencyConfig>
Pro Tip & Optimization

Use satisfies instead of a type annotation when you need compile-time exhaustiveness checking but also need the precise literal types of the values for downstream inference. It's the difference between a "shape check" and a "widening cast".


8. Cross-Language Rosetta: Nominal Primitives

The same architectural problem — differentiating domain primitives that share an underlying type — appears across languages:

Pattern TypeScript 5+ Go 1.20+ Python 3.10+
Nominal string ID Brand<string, 'OrderId'> type OrderID string OrderId = NewType('OrderId', str)
Type-safe creation Smart constructor function Conversion function func NewOrderID(s string) OrderID Validation function with NewType
Compile-time check TS compiler rejects wrong brand Go compiler rejects implicit conversion mypy rejects wrong NewType
Runtime overhead Zero — phantom type erased Zero — distinct type is resolved at compile Zero — NewType is identity at runtime
Exhaustiveness switch + satisfies + assertNever Type switch switch v := x.(type) match + case (Python 3.10+)

Go's distinct type system is nominally stricter than TypeScript's brand pattern: you cannot accidentally assign an OrderID to a CustomerID even if both are string underneath, because the Go compiler tracks names. Python's NewType provides the same guarantee through mypy, but not CPython's runtime.


Summary

Concept Rule
Structural typing TypeScript checks shape, not name — two classes with identical fields are interchangeable
Brand pattern Attach a phantom type tag (Brand<T, 'Tag'>) to make structurally identical types nominally distinct
Smart constructors The only sanctioned way to produce branded values — the single validation enforcement point
Template literal brands Enforce string prefix schemas (txn_${string}) at compile time without runtime parsing
Polymorphic this Return this instead of a named class to preserve derived types through fluent chains
Discriminated unions Prefer over class hierarchies for pure data variants — zero prototype overhead, native exhaustiveness
satisfies operator Validates against a type without widening — ideal for exhaustive registries and lookup maps

What's Next

In Part 3, we move from modeling domain primitives to defining the contracts between collaborating components. The choice between interface, type, and abstract class is not stylistic — it determines runtime bundle footprint, dynamic dispatch tables, and the architectural flexibility of your public API boundaries. Part 3: Contracts, Interfaces & Abstraction Hierarchies covers the mechanical decision heuristics, TypeScript 5+ mixins, and noImplicitOverride to prevent silent contract breakage.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#TypeScript#Type System#Nominal Types#Domain Modeling#Branded Types#LLD Interview
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.