loke.dev
Header image for 4 Real-World Bugs That Disappear When You Start Using Branded Types

4 Real-World Bugs That Disappear When You Start Using Branded Types

Stop treating every string like an ID and start using nominal typing to prevent the most common logic errors in large-scale TypeScript apps.

· 4 min read

TypeScript is too nice. It spends its whole life trying to convince you that if two things look the same, they *are* the same, which is great until you accidentally ship a feature that deletes a user's account because you passed a ProjectId into a function expecting a UserId.

Because TypeScript uses a structural type system, a string is a string. It doesn't matter if you named the variable emailAddress or dbConnectionString; as far as the compiler is concerned, they are interchangeable. Branded types (or nominal typing) are the "stop being so nice" switch. They allow you to tell the compiler: "I know these are both strings, but if I try to mix them up, I want you to scream at me."

Here are four real-world bugs that simply stop existing once you start branding your types.

1. The "ID Swap" Disaster

We’ve all been there. You have a function that takes two IDs. Maybe you're moving a document from one folder to another.

function moveDocument(documentId: string, targetFolderId: string) {
  // database logic...
}

const docId = "doc_123";
const folderId = "folder_456";

// Oops, I swapped them. TypeScript sees string, string. No errors.
moveDocument(folderId, docId);

You won't catch this until your integration tests fail—or worse, until a customer asks why their folders are disappearing. By "branding" these strings, we make them unique.

type Brand<K, T> = T & { __brand: K };

type DocumentId = Brand<'DocumentId', string>;
type FolderId = Brand<'FolderId', string>;

function moveDocument(docId: DocumentId, folderId: FolderId) { ... }

// Now this is a compile-time error. 
// FolderId is not assignable to DocumentId.
moveDocument(folderId, docId); 

The __brand property doesn't actually exist at runtime. It's a ghost in the machine that only the compiler can see.

2. The "Is This Email Actually Validated?" Loophole

Imagine you have a complex regex for validating emails. You use it in your API routes, your signup forms, and your newsletter logic. But eventually, someone writes a helper function that sends a "Welcome" email, and they just type the argument as string.

Now, a raw, unvalidated string from a legacy database entry or a sketchy CSV import can sneak into your mailer service and crash it.

type ValidatedEmail = Brand<'ValidatedEmail', string>;

function validateEmail(email: string): ValidatedEmail {
  if (!email.includes('@')) throw new Error("Invalid email");
  return email as ValidatedEmail;
}

function sendWelcomeEmail(email: ValidatedEmail) {
  // I can sleep easy knowing this string has passed the gates
}

// Error: Argument of type 'string' is not assignable to 'ValidatedEmail'
sendWelcomeEmail("not-an-email"); 

// Success
sendWelcomeEmail(validateEmail("hello@example.com"));

By requiring ValidatedEmail instead of string, you force the developer to pass the data through your validation logic first. You’ve moved the "did I check this?" anxiety from your brain to the compiler.

3. The "Wait, is this Milliseconds or Seconds?" Mixup

If I had a nickel for every time an API timeout was broken because one dev thought in seconds and another thought in milliseconds, I’d have enough to buy a very expensive coffee.

JavaScript's setTimeout takes milliseconds. Many Unix-based APIs return seconds.

type Seconds = Brand<'Seconds', number>;
type Milliseconds = Brand<'Milliseconds', number>;

const SESSION_TIMEOUT = 3600 as Seconds;

function setAppTimeout(duration: Milliseconds) {
  setTimeout(() => logout(), duration);
}

// Error! You're trying to timeout in 3600ms (3.6 seconds) 
// instead of 3600 seconds.
setAppTimeout(SESSION_TIMEOUT); 

This is particularly useful in financial apps (Cents vs. Dollars) or any domain involving units of measurement. It’s hard to accidentally bankrupt a company when the compiler refuses to let you add USD to JPY without an explicit conversion step.

4. The "Internal ID Leak"

In many systems, you have internal database IDs (like an auto-incrementing integer) and public-facing IDs (like a HashID or a UUID).

You never, ever want to accidentally leak your internal 123 ID to the client in a JSON response because it makes your system easy to crawl.

type InternalId = Brand<'InternalId', number>;
type PublicId = Brand<'PublicId', string>;

interface User {
  id: InternalId;
  publicId: PublicId;
  name: string;
}

function formatUserForClient(user: User) {
  return {
    // If I accidentally put user.id here, TS catches it 
    // because the return type expects a string-branded PublicId
    id: user.publicId, 
    name: user.name
  };
}

How to actually use them (The "Secret Sauce")

You might be thinking: "Isn't it annoying to keep casting things with as DocumentId?"

The trick is to use Type Guards or Assertion Functions. You shouldn't be casting everywhere in your business logic. Instead, you cast at the "border" of your application—when data enters from an API, a form, or a database.

// border of the app
function parseUserId(id: string): UserId {
  if (!isValid(id)) throw new Error("Invalid ID");
  return id as UserId;
}

Once it's cast at the border, the rest of your app flows through a type-safe pipe. You get the safety of a language like Haskell or Rust without losing the flexibility of TypeScript.

Branded types have zero runtime overhead. They vanish when the code is transpiled to JavaScript. You're getting better security and fewer 3:00 AM PagerDuty calls for the low, low price of absolutely nothing. Stop treating every string like a generic bucket of characters—give your data some identity.