Right, so you’ve met TypeScript’s structural typing system. It’s mostly brilliant, it’s the reason the ecosystem works so well, and right now, you probably want to throw it out a window. Because sometimes, a string is just… too much of a string.

Let me show you what I mean. Imagine you’re building a banking API (a terrifying thought, I know). You have a function to debit an account.

function debitAccount(accountId: string, amount: number) {
    // ... scary financial logic
}

Seems fine. Now, elsewhere, you have a function to get a user’s loyalty card ID.

function getLoyaltyCardId(userId: string): string {
    // ... gets the card ID
    return "loyalty-12345";
}

See the problem? Structural typing does.

const loyaltyCardId = getLoyaltyCardId("user-567");
// "loyalty-12345"

// A catastrophic, but perfectly valid, TypeScript assignment
debitAccount(loyaltyCardId, 100.00); // 💥 Funds now debited from a loyalty card?!

TypeScript sees no issue here. Both accountId and loyaltyCardId are of type string. As far as the type system is concerned, they are interchangeable. This is the “accidental assignability” that will keep you up at night. Your loyalty card ID is structurally identical to a bank account ID, but they are semantically worlds apart. We need a way to teach that distinction to the type checker.

The Naive First Attempt: Type Aliases

Your first instinct might be to create type aliases. It feels like it should work.

type AccountID = string;
type LoyaltyCardID = string;

function debitAccount(accountId: AccountID, amount: number) { /* ... */ }
function getLoyaltyCardId(userId: string): LoyaltyCardID { /* ... */ }

Let’s try our catastrophic assignment again:

const cardId: LoyaltyCardID = getLoyaltyCardId("user-567");
debitAccount(cardId, 100.00); // 😑 STILL NO ERROR

Why? Because TypeScript’s type system is structural, not nominal. It doesn’t care about the name AccountID; it only cares that the value you’re passing is a string. AccountID is just a fancy hat on a string. At runtime, it’s stripped away completely. This gives us exactly zero additional type safety.

The Core Issue: Proving Uniqueness

The problem is that we need to make these types structurally different. We need to modify the actual structure of the string type for AccountID so it’s no longer compatible with a vanilla string or our LoyaltyCardID.

We need to attach a hidden property, a “brand,” that only exists at compile time to break the structural compatibility. This is where we move from naive aliases to branded types.

Introducing the Branded Type Pattern

The classic, battle-tested pattern is to intersect your base type (here, string) with a unique object type. This object type has a property with a unique name (the “brand”) whose type is something impossible, like a unique symbol.

// Declare a unique brand for accounts
declare const accountIdBrand: unique symbol;

// Create a branded type
type AccountID = string & { readonly [accountIdBrand]: never };

// Do the same for loyalty cards
declare const loyaltyIdBrand: unique symbol;
type LoyaltyCardID = string & { readonly [loyaltyIdBrand]: never };

Let’s break this down. We’re saying an AccountID is a string and also an object that has a property keyed by our unique symbol, accountIdBrand, of type never. The never type is a clever trick here—it means this property can never actually be assigned a value. This is a compile-time-only construct.

Now, look what happens:

function getLoyaltyCardId(userId: string): LoyaltyCardID {
    // We have to assert the return type because the literal
    // string isn't automatically a LoyaltyCardID
    return "loyalty-12345" as LoyaltyCardID;
}

const cardId = getLoyaltyCardId("user-567");
debitAccount(cardId, 100.00); // 🎉 FINALLY! TypeScript Error:
// Argument of type 'LoyaltyCardID' is not assignable to parameter of type 'AccountID'.

Success! The type system now recognizes that these two types, while both based on string, are fundamentally incompatible. You can’t accidentally assign one to the other without an explicit, intentional type assertion (as AccountID), which is your way of telling the compiler, “I know what I’m doing, shut up.”

The Reality of Creation and Validation

You can’t just cast any old string willy-nilly, or you’re right back where you started. The only safe way to create a branded type is through a validation function. This is the crucial best practice.

function createAccountID(rawId: string): AccountID {
    // Validate the format of the string at runtime!
    if (!/^acct_[a-zA-Z0-9]+$/.test(rawId)) {
        throw new Error(`Invalid account ID format: ${rawId}`);
    }
    // Only after validation do we assert the type
    return rawId as AccountID;
}

// Usage: Safe creation from untrusted input
const myAccountId = createAccountID("acct_abc123"); // OK
const myInvalidAccountId = createAccountID("loyalty-12345"); // Throws runtime Error

This pattern is the complete solution: it gives you compile-time safety and runtime validation. The branded type ensures you don’t mix up already-validated IDs, and the validation function ensures you can’t create a branded type from invalid data. It’s the one-two punch for type safety.