Let’s be honest: you’ve probably faked an ID or two in your time. Not the kind that gets you into a bar, the kind that gets you past the type checker. You’ve typed id: string and then prayed that the string you got back from fetchUser() is the same kind of string you’re about to pass into processOrder(). This is a recipe for the most insidious kind of bug: the one that happens at runtime because your types were lying to you.

We can do better. We can create IDs that are truly distinct at the type level, making it impossible to accidentally use a ProductId where a UserId is expected, even though under the hood, they’re all just strings. The compiler will become your bouncer, checking IDs at the door.

The Problem with Primitive Obsession

The core issue is called “primitive obsession”—using a primitive type like string or number to represent a domain concept. To the type system, "user_abc123" and "product_xyz789" are identical. They’re both strings. This means this code is perfectly valid, and utterly wrong:

type User = { id: string; name: string };
type Product = { id: string; name: string };

declare function getUser(id: string): User;
declare function deleteProduct(id: string): void;

const user = getUser("user_abc123");
// ... later, in a different file, a tired developer...
deleteProduct(user.id); // 💥 Deletes the user! Compiler says ✅

We need to break this equivalence. We need to brand our primitives.

Branded Types: Your Type-Safe Bouncer

A branded type (or “nominal type”) is a technique where we “tag” a primitive type with a unique property, making it incompatible with other primitives—and even its own underlying type—unless explicitly converted. Here’s how we create a UserId:

// A unique brand symbol. The `unique symbol` type ensures
// this property name is unique across the entire codebase.
declare const userIdBrand: unique symbol;

// The branded type itself. It's a string, but it has a special,
// unique property that only exists at the type level.
type UserId = string & { readonly [userIdBrand]: true };

// Function to create a UserId. This is the ONLY way to get one.
function UserId(id: string): UserId {
    // You could add validation here! Check for a prefix, a UUID format, etc.
    if (!id.startsWith('user_')) {
        throw new Error("Invalid User ID format");
    }
    return id as UserId; // The cast is the magic. We're telling TS to trust us.
}

// Now, let's define our functions properly:
type User = { id: UserId; name: string };
declare function getUser(id: UserId): User;

const myUserId = UserId("user_abc123"); // This is a UserId
const user = getUser(myUserId); // ✅ Correct

const someProductIdString = "product_xyz789";
getUser(someProductIdString); // ❌ Compiler Error: string is not assignable to UserId
getUser("user_abc123"); // ❌ Still an error! 'string' is not 'UserId'

See what happened? You can’t even pass a literal string anymore. The only way to get a UserId is to use the UserId constructor function, which gives us a single, controlled place to validate the format. This is a massive win.

Opaque Types: Hiding the Implementation

The above example has one small flaw: UserId is still technically assignable to string. This means you could bypass our safety by doing:

const id: UserId = UserId("user_abc123");
const stringId: string = id; // ✅ This works, which is a bit of a leaky abstraction
getUser(stringId as UserId); // ❌ But you'd still need an unsafe cast to break things

If you want to be even stricter, you can use an opaque type pattern. An opaque type hides the underlying implementation completely. It’s often implemented using a private class property or a symbol.

const userIdSymbol = Symbol('UserId');

class OpaqueUserId {
    // The private property is the brand. It exists only at compile time.
    private readonly [userIdSymbol]: undefined;
    // The public value holds the actual data.
    public readonly value: string;

    constructor(value: string) {
        this.value = value;
    }

    // Override toString for convenience, so it can still be used in logs/URLs.
    toString(): string {
        return this.value;
    }
}

declare function getUser(id: OpaqueUserId): User;

const userId = new OpaqueUserId("user_abc123");
getUser(userId); // ✅

// These are now completely impossible:
const stringId: string = userId; // ❌ Type 'OpaqueUserId' is not assignable to type 'string'.
getUser("user_abc123"); // ❌ Argument of type 'string' is not assignable to parameter of type 'OpaqueUserId'.

The opaque version is more robust but also more verbose. You have to access id.value to get the raw string, which some might find annoying. The choice between branded and opaque is a trade-off between absolute safety and convenience. For most applications, the branded type is plenty safe enough.

Phantom Types: The Generic Brand

What if you have a dozen different ID types? Repeating the brand symbol declaration for each one gets tedious. This is where phantom types with a generic brand come in. We create a single, reusable Brand type.

// A generic branded type. The `T` is the "phantom" type parameter—it's only used
// in the type system to create uniqueness, never at runtime.
type Brand<T, BrandName> = T & { readonly __brand: BrandName };

// Now defining IDs is a one-liner:
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type ProductId = Brand<string, 'ProductId'>;

// Constructor functions are still a good idea for validation.
function createUserId(id: string): UserId {
    validateId(id, 'user');
    return id as UserId;
}

function createOrderId(id: string): OrderId {
    validateId(id, 'order');
    return id as OrderId;
}

const uid = createUserId("user_abc123");
const oid = createOrderId("order_def456");

useUserId(uid); // ✅
useUserId(oid); // ❌ Type '"OrderId"' is not assignable to type '"UserId"'.

This pattern is elegant, scalable, and the industry standard for this kind of problem. The __brand property is a convention; you can call it whatever you want, as long as the BrandName is unique for each type.

Best Practices and Pitfalls

  1. Validation is Your Job: The type system ensures you don’t mix up valid IDs. It’s your responsibility to ensure the string you’re branding is actually valid in the first place. Do this in your constructor function. Never cast a random string to a branded type without checks.
  2. Serialization/Deserialization: When you get an ID from an API (e.g., JSON), it’s a string. You must validate and convert it to your branded type at your application’s boundary (e.g., in your API fetch function). Don’t let raw strings deep into your core logic.
  3. Don’t Overdo It: This pattern is most valuable for concepts that are used across many domains and have serious consequences if mismatched (IDs, EmailAddresses, CurrencyCodes, etc.). Branding every single string in your app is overkill and adds cognitive overhead.
  4. Runtime Debugging: At runtime, a branded type is just its primitive value. console.log(userId) will print "user_abc123". This is good! It keeps things simple for logging and interop with other libraries. The magic is purely at compile time.

By adopting this pattern, you move entire categories of bugs from silent runtime failures to loud compile-time errors. It’s a hallmark of a thoughtful, robust TypeScript codebase. It tells everyone that you’re not just using TypeScript; you’re leveraging it.