TypeScript's type system is fundamentally built upon Structural Subtyping. Two types are considered fully equivalent and interchangeable if they share an identical set of properties with matching types, regardless of their declared names, module origin, or domain intent.
In software design, structural typing frequently leads to the Primitive Obsession antipattern. When user identifiers (UserId), order IDs (OrderId), and session tokens (SessionToken) are all declared as the primitive type string, the compiler cannot detect when one is mistakenly passed in place of another:
// An error that a structurally typed compiler CANNOT detect:
function transferFunds(senderId: string, recipientId: string, amountCents: number) {}
const currentUserId = 'usr_1024';
const targetOrderId = 'ord_9876';
// Bug: Passing OrderId instead of RecipientId compiles without warnings!
transferFunds(currentUserId, targetOrderId, 5000);To eliminate this class of defects in Domain-Driven Design (DDD), systems leverage Nominal Typing. In TypeScript, nominal typing is emulated via Branded Types (or Opaque Types) — intersecting a base primitive with a phantom symbol property unrepresentable in ordinary runtime code.
+-------------------------------------------------------------------------+
| STRUCTURAL VS NOMINAL TYPING COMPARISON |
+-------------------------------------------------------------------------+
| [Structural Typing: Primitives are equivalent] |
| type UserId = string; |
| type OrderId = string; |
| string === string -> Total interchangeability (Dangerous for domains!) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [Branded Types: Isolated Domain Entities] |
| type UserId = string & { readonly [__brand]: 'UserId' }; |
| type OrderId = string & { readonly [__brand]: 'OrderId' }; |
| Compiler: |
| Type '{ [__brand]: "OrderId" }' is not assignable to '{ [__brand]: "UserId" }'|
+-------------------------------------------------------------------------+A branded type is formed by the intersection of a base primitive with a phantom type containing a unique symbol:
Lifecycle of a Branded Type:
[Untrusted input from HTTP/DB: string]
|
v
[Smart Constructor / Zod Schema]
Validation: regex.test(raw), uuid.validate(raw)
/ \
(Success) (Failure)
/ \
v v
[UserId (Brand)] [DomainError / Throw]
|
v
[Domain services accept ONLY validated UserId]UsdCents, EurCents) to prevent cross-currency addition at compile time.TenantId, OrganizationId, and AccountId to eliminate data leaks caused by swapped SQL query parameters.SanitizedHtml) to prevent cross-site scripting (XSS) before rendering into the DOM.EmailAddress, E164PhoneNumber, IsoDateString) without redundant re-validation across layered service calls.To establish a resilient branding system, we introduce a global phantom symbol:
declare const __brand: unique symbol;
/**
* Core utility type for constructing nominal types (Strict Brands).
*/
export type Brand<T, TBrand extends string | symbol> = T & {
readonly [__brand]: TBrand;
};
/**
* Alternative: Soft Branding (Flavoring).
* Allows implicit assignment to raw primitives while preventing cross-flavor assignment.
*/
declare const __flavor: unique symbol;
export type Flavor<T, TFlavor extends string | symbol> = T & {
readonly [__flavor]?: TFlavor;
};Enforcing boundaries between IDs and currency units in a banking domain:
import { Brand } from '../types/brand';
// Branded type declarations
export type UserId = Brand<string, 'UserId'>;
export type AccountId = Brand<string, 'AccountId'>;
export type UsdCents = Brand<number, 'UsdCents'>;
// Smart Constructor for UserId with UUIDv4 validation
export function parseUserId(raw: string): UserId {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(raw)) {
throw new TypeError(`[DomainError] Invalid UserId format: ${raw}`);
}
// The only permitted type assertion is isolated within the validating factory
return raw as UserId;
}
// Smart Constructor for monetary quantities validating non-negative integers
export function createUsdCents(amount: number): UsdCents {
if (!Number.isSafeInteger(amount) || amount < 0) {
throw new RangeError(
`[DomainError] Monetary amount in cents must be a non-negative integer. Received: ${amount}`
);
}
return amount as UsdCents;
}
// Domain service with compiler protection against transposed arguments
export function processTransfer(
fromAccount: AccountId,
toAccount: AccountId,
amount: UsdCents,
authorizedBy: UserId
): void {
// Business logic execution
console.log(
`Transferring ${amount} cents from ${fromAccount} to ${toAccount} (authorized by: ${authorizedBy})`
);
}In production systems, branding is often coupled with runtime schema validators like Zod:
import { z } from 'zod';
import { Brand } from '../types/brand';
export type UserEmail = Brand<string, 'UserEmail'>;
export const UserEmailSchema = z
.string()
.email('Invalid email syntax')
.transform((val) => val.toLowerCase().trim())
.refine((val) => val.length <= 255, 'Email exceeds maximum length of 255 characters')
// Pipe into custom brand:
.pipe(z.custom<UserEmail>());
export interface RegisterUserDto {
readonly email: UserEmail;
readonly fullName: string;
}
export function parseRegisterUserPayload(input: unknown): RegisterUserDto {
const Schema = z.object({
email: UserEmailSchema,
fullName: z.string().min(2),
});
return Schema.parse(input);
}Receive unverified JSON payloads from external HTTP requests or databases typed as unknown or raw string.
Verify business invariants (RFC formats, numeric bounds, checksums) inside an isolated factory function.
Upon successful validation, the raw primitive is cast to Brand<T, 'Tag'>. In emitted JS, it remains an untransformed primitive.
Downstream domain methods demand UserId exclusively. Passing raw strings triggers compile-time error TS2345.
import { Brand } from '../types/brand';
export type USD = Brand<number, 'Currency_USD'>;
export type EUR = Brand<number, 'Currency_EUR'>;
export type GBP = Brand<number, 'Currency_GBP'>;
export type SupportedCurrency = USD | EUR | GBP;
function assertValidAmount(amount: number): void {
if (!Number.isSafeInteger(amount)) {
throw new TypeError(
`[CurrencyError] Amount must be a safe integer in minor units (cents). Received: ${amount}`
);
}
}
export const Money = {
usd: (cents: number): USD => {
assertValidAmount(cents);
return cents as USD;
},
eur: (cents: number): EUR => {
assertValidAmount(cents);
return cents as EUR;
},
gbp: (cents: number): GBP => {
assertValidAmount(cents);
return cents as GBP;
},
/**
* Adds monetary values of the exact same currency.
* Adding USD to EUR is physically prevented at compile time!
*/
add: <T extends number>(a: Brand<T, string>, b: Brand<T, string>): Brand<T, string> => {
const result = (a as number) + (b as number);
assertValidAmount(result);
return result as Brand<T, string>;
},
/**
* Multiplies monetary amount by a scalar factor (interest rate, tax)
*/
multiply: <T extends number>(amount: Brand<T, string>, factor: number): Brand<T, string> => {
if (!Number.isFinite(factor) || factor < 0) {
throw new RangeError(`[CurrencyError] Factor must be a finite positive number`);
}
const result = Math.round((amount as number) * factor);
assertValidAmount(result);
return result as Brand<T, string>;
},
};Defining a brand as type UserId = string & { __brand: 'UserId' } exposes __brand as an ordinary object property. At runtime, the property is absent, and developers might accidentally hand-craft matching object literals.
// ❌ ANTIPATTERN: Ordinary string key risks collisions
type BadUserId = string & { _brand: 'UserId' };
// Runtime issue: strings do not have physical _brand properties
const id: BadUserId = '123' as BadUserId;
console.log('_brand' in (id as any)); // false!
// ✅ CORRECT: Utilizing unique symbol
declare const __brand: unique symbol;
type GoodUserId = string & { readonly [__brand]: 'UserId' };Scattering as UserId assertions across application controllers defeats the purpose of nominal typing. Brand creation must remain strictly confined within Smart Constructors.
// ❌ ANTIPATTERN: Manual cast of unvalidated string in controller
const userId = req.body.id as UserId; // SECURITY HOLE: Validation bypassed!
// ✅ CORRECT: Invoking the validating factory
const userId = parseUserId(req.body.id);Any operation on a branded primitive (id.trim(), id.toLowerCase(), amount + 10) evaluates to the base unbranded primitive (string or number), stripping the brand metadata.
const userId = parseUserId('d3b07384-d113-4672-8597-873b879c464e');
// trim() returns string, brand is dropped!
// const trimmedId: UserId = userId.trim(); // COMPILE ERROR TS2322!
// ✅ CORRECT: Re-verifying or wrapping in helper functions
const cleanedUserId = parseUserId(userId.trim());Branding large composite entities is unnecessary and makes object destructuring cumbersome. Branding should be reserved for atomic scalars (IDs, keys, currency values).
// ❌ ANTIPATTERN: Branding an entire user model
type BrandedUser = User & Brand<unknown, 'ValidUser'>;
// ✅ CORRECT: Branding atomic primitive keys
interface User {
readonly id: UserId;
readonly email: UserEmail;
}Identify primitive scalars where accidental substitution creates business or security risks (user IDs, tokens, currency amounts).
Establish a Brand<T, Tag> utility backed by declare const __brand: unique symbol.
Author factory functions (parseUserId, createUsdCents) enforcing format invariants before asserting the brand.
Replace raw strings and numbers in method signatures with corresponding branded types.
Execute pnpm typecheck and update controller entry points to invoke smart constructors.