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.
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:
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.
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
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:
3.3 Branded Types in Action: Compile-Time Currency Safety
The type errors appear in the IDE before the code runs. No tests needed for this class of bug — the compiler is the test.

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
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:
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
5.2 The Fix: Polymorphic this Return Type
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)
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)
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 |

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.
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, andabstract classis 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, andnoImplicitOverrideto prevent silent contract breakage.
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.