惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Stack Overflow Blog
Stack Overflow Blog
J
Java Code Geeks
Last Week in AI
Last Week in AI
人人都是产品经理
人人都是产品经理
博客园 - 【当耐特】
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
C
Check Point Blog
月光博客
月光博客
腾讯CDC
Engineering at Meta
Engineering at Meta
博客园 - Franky
Vercel News
Vercel News
D
Docker
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
F
Fortinet All Blogs
Microsoft Security Blog
Microsoft Security Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
雷峰网
雷峰网
Google DeepMind News
Google DeepMind News
Martin Fowler
Martin Fowler
GbyAI
GbyAI
B
Blog
Hugging Face - Blog
Hugging Face - Blog
T
Tailwind CSS Blog

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution
TypeScript Branded Types: Stop Passing the Wrong ID at Co...
The Practica · 2026-06-17 · via The Practical Developer

A user’s payment was charged to someone else’s order. Not a SQL injection. Not a race condition. The developer called findOrder(userId) instead of findOrder(orderId), and TypeScript said nothing because both arguments were string.

This bug is not rare. It happens in every codebase that grows beyond a handful of files. TypeScript’s structural type system treats any string as compatible with any other string. If your domain has a UserId, an OrderId, and a ProductId and all three are plain strings, the compiler cannot distinguish between them. A function that expects an OrderId will happily accept a UserId, and the mistake surfaces at runtime as a 404, a wrong charge, or a corrupted record.

This post shows you how to fix that with branded types: a zero-cost abstraction that turns TypeScript’s structural type system into a nominally typed one for the identifiers that matter in your domain. You will get a compile-time error when you pass a UserId where an OrderId belongs, and you will pay exactly zero bytes at runtime.

Why plain types are not enough

Here is the problem in its simplest form:

type UserId = string;
type OrderId = string;

async function getOrder(orderId: OrderId): Promise<Order> { /* ... */ }
async function getUser(userId: UserId): Promise<User> { /* ... */ }

// This compiles. It should not.
const user = await getUser(orderId); // orderId is an OrderId, not a UserId

TypeScript sees string on both sides and calls it a day. It does not care that your domain concepts are different. It only checks the shape, and the shape of UserId and OrderId is identical: a string.

Type aliases are documentation, not enforcement. They help you read code, but they do not prevent you from writing bugs.

You could wrap every ID in a class:

class UserId { constructor(public readonly value: string) {} }
class OrderId { constructor(public readonly value: string) {} }

Now the compiler catches the mistake. But every function call becomes getUser(new UserId(someString)), every comparison requires .value, and every serialization step needs custom JSON handling. The runtime cost adds up. The ergonomic tax is high enough that most teams give up after a week.

Branded types give you the safety of nominal typing with none of the runtime overhead.

The branded type pattern

A branded type uses an intersection of the underlying type with a phantom type that only exists at compile time:

declare const UserIdBrand: unique symbol;
type UserId = string & { [UserIdBrand]: true };

declare const OrderIdBrand: unique symbol;
type OrderId = string & { [OrderIdBrand]: true };

The unique symbol declarations and the intersection with { [UserIdBrand]: true } create a type that is still a string at runtime (no wrapper object, no class instance) but is distinct at the type level. The declare const ... is never emitted in the compiled JavaScript. It disappears entirely.

Now this is a compile error:

async function getOrder(orderId: OrderId): Promise<Order> { /* ... */ }

declare const orderId: OrderId;
declare const userId: UserId;

getOrder(userId); // Error: Type 'UserId' is not assignable to type 'OrderId'.

The error message points directly to the bug: you passed a UserId where an OrderId was expected. The compiler saved you from a production incident.

Building a generic brand utility

Writing out the brand declaration for every type gets repetitive. Create a reusable utility:

declare const Brand: unique symbol;
export type Brand<T, B> = T & { [Brand]: B };

Now define your domain types with a single line each:

export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;
export type ProductId = Brand<string, 'ProductId'>;
export type EmailAddress = Brand<string, 'EmailAddress'>;
export type PhoneNumber = Brand<string, 'PhoneNumber'>;
export type SKU = Brand<string, 'SKU'>;

The second type parameter ('UserId') is the brand name. It does not need to match anything. It just needs to be unique so the types are structurally distinct.

You can brand any primitive, not just strings:

export type UserAge = Brand<number, 'UserAge'>;
export type OrderTotal = Brand<number, 'OrderTotal'>;
export type ProductRating = Brand<number, 'ProductRating'>;
export type PositiveInteger = Brand<number, 'PositiveInteger'>;

A function that expects OrderTotal rejects UserAge at compile time, even though both are numbers.

The guardrail pattern: brand at boundaries, unbox internally

The most important rule of branded types: create branded values at trusted boundaries (API handlers, database queries, constructor functions) and use the raw type internally when you need to.

Here is the pattern:

// Boundary: the only place a UserId is created from user input
function userIdFromString(value: string): UserId {
  if (!/^[a-f0-9]{24}$/.test(value)) {
    throw new Error(`Invalid user ID format: ${value}`);
  }
  return value as unknown as UserId;
}

// Service layer: every function is type-safe
async function getOrdersForUser(userId: UserId): Promise<Order[]> {
  // Internally, cast to string when needed
  const result = await db.query(
    'SELECT * FROM orders WHERE user_id = $1',
    [userId as string]
  );
  return result.rows;
}

The cast value as unknown as UserId is the only unsafe operation. It lives in exactly one place per type: the constructor function. Every consumer of that type from that point on is compile-time safe. Review that one line in code review, and the rest of the codebase is guaranteed correct.

This is the inversion of most type-safety discussions. Instead of making every function validate its inputs (which nobody does consistently), you validate once at the boundary and encode the result in the type system. The compiler enforces the rest.

Real example: refactoring a payment flow

Consider a payment processing pipeline. Before branded types, it looks like this:

async function processPayment(userId: string, orderId: string, amount: number) {
  const user = await findUser(userId);
  const order = await findOrder(orderId);

  if (!user) throw new Error('User not found');
  if (!order) throw new Error('Order not found');

  return chargeCard(user.paymentToken, amount);
}

// Called from a route handler
router.post('/pay', async (req, res) => {
  await processPayment(req.body.userId, req.body.orderId, req.body.amount);
});

Every argument is string. Nothing stops a caller from swapping userId and orderId. Nothing validates that the amount is positive.

With branded types:

export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;
export type Amount = Brand<number, 'PositiveAmount'>;

// Single trusted boundary per type
const UserId = {
  from: (s: string): UserId => s as unknown as UserId,
};
const OrderId = {
  from: (s: string): OrderId => s as unknown as OrderId,
};
const Amount = {
  from: (n: number): Amount => {
    if (n <= 0 || !Number.isFinite(n)) {
      throw new Error(`Invalid amount: ${n}`);
    }
    return n as unknown as Amount;
  },
};

async function processPayment(
  userId: UserId,
  orderId: OrderId,
  amount: Amount
) {
  const user = await findUser(userId);
  const order = await findOrder(orderId);

  if (!user) throw new Error('User not found');
  if (!order) throw new Error('Order not found');

  return chargeCard(user.paymentToken, amount as number);
}

// Route handler: validate and convert at the boundary
router.post('/pay', async (req, res) => {
  try {
    const userId = UserId.from(req.body.userId);
    const orderId = OrderId.from(req.body.orderId);
    const amount = Amount.from(Number(req.body.amount));

    await processPayment(userId, orderId, amount);
    res.json({ status: 'ok' });
  } catch (err) {
    res.status(400).json({ error: (err as Error).message });
  }
});

Now any call to processPayment where arguments are swapped produces a compile-time error. The validation logic for each type is centralized in one place. The route handler catches formatting errors early and returns a clean 400. The domain logic never needs to re-validate.

Branded IDs in database queries

Real applications query by ID constantly. The pattern above works well for a single ID, but what about finding a user by ID?

// Repository layer
async function findUserById(id: UserId): Promise<User | null> {
  const row = await pool.query(
    'SELECT * FROM users WHERE id = $1',
    [id as string]  // safe cast: we control the source
  );
  return row.rows[0] ?? null;
}

// Service layer
async function getUserProfile(userId: UserId): Promise<UserProfile> {
  const user = await findUserById(userId);
  if (!user) throw new NotFoundError('User', userId);
  return { id: userId, name: user.name, email: user.email };
}

The pattern stays consistent: the repository layer does the as string cast internally, and the service layer never sees a raw string. If a developer tries to call findUserById(someOrderId), TypeScript stops the build.

Common objections and why they are wrong

“It adds boilerplate.” One type definition and one constructor function per domain concept. That is less boilerplate than the runtime validation and the debugging time you spend hunting mismatched-ID bugs.

“The as unknown as T cast is unsafe.” It is the least unsafe cast in the codebase because it is isolated to a single constructor function per type. Any bug in that cast is caught by the tests for that one function. Compare that to twenty scattered as string casts that each might be wrong in a different way.

“TypeScript has opaque types proposals.” The TC39 proposal for opaque types has been in stage 0 for years. Branded types work today in TypeScript 4.x and later. Do not wait for a standard that may never ship. Use what compiles now.

“Just use classes.” Classes add runtime cost. Every new UserId(s) allocates an object. Every function call that accepts a UserId has to unwrap .value. Branded types are structurally identical to the underlying type at runtime. typeof userId returns "string". JSON.stringify({id: userId}) produces {"id":"abc"}. No custom serialization. No extra allocations. Zero bytes.

“I can just be careful.” No, you cannot. Human attention is the most expensive and unreliable resource in engineering. Let the type system enforce what you cannot trust yourself to remember at 3pm on a Friday.

When to use branded types (and when not to)

Use branded types when:

  • Your function accepts a primitive and the meaning matters (UserId, OrderId, Email)
  • You have two or more domain concepts that share the same underlying type
  • You want to prevent accidental swapping of arguments
  • You want to encode validation guarantees in the type system
  • The type crosses module boundaries (the further it travels, the more value you get)

Do not use branded types when:

  • The type is purely internal to one function and never passed around
  • You already have a class or a dedicated type (like a URL object) that carries the semantics
  • The primitive value has no semantic distinction from other values of the same type (e.g., a measurement in millimeters vs centimeters, which should use a dimensioned type instead)

Testing the boundary constructors

The constructor functions are the most important code to test because they are the single trusted entry point for each type:

describe('UserId', () => {
  it('creates a valid UserId from a hex string', () => {
    const id = UserId.from('507f1f77bcf86cd799439011');
    expect(typeof id).toBe('string');
  });

  it('rejects invalid formats', () => {
    expect(() => UserId.from('not-a-hex')).toThrow('Invalid user ID format');
    expect(() => UserId.from('')).toThrow('Invalid user ID format');
    expect(() => UserId.from('   ')).toThrow('Invalid user ID format');
  });
});

A rejection in the constructor means the error manifests as a clear 400 at the API boundary, not a mysterious 500 deep in a service function that forgot to validate.

Putting it all together: a complete pattern

Here is the full pattern you can drop into your project today:

// brand.ts -- utility
declare const Brand: unique symbol;
export type Brand<T, B> = T & { [Brand]: B };

// brand-factory.ts -- reusable factory
export function createBrandedType<T extends string | number, Name extends string>(
  name: string,
  validate?: (value: T) => boolean
) {
  const from = (value: T): Brand<T, Name> => {
    if (validate && !validate(value)) {
      throw new Error(`Invalid ${name}: ${value}`);
    }
    return value as unknown as Brand<T, Name>;
  };
  return { from };
}

// domains.ts -- your domain types
export const UserId = createBrandedType<string, 'UserId'>('UserId', (s) =>
  /^[a-f0-9]{24}$/i.test(s)
);
export const OrderId = createBrandedType<string, 'OrderId'>('OrderId', (s) =>
  /^ord_[a-z0-9]{20}$/i.test(s)
);
export const SKU = createBrandedType<string, 'SKU'>('SKU', (s) =>
  /^[A-Z]{2,4}-\d{4,8}$/.test(s)
);

export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;
export type SKU = Brand<string, 'SKU'>;

Now UserId and OrderId are distinct types that cannot be confused at compile time. The factory handles validation and the unsafe cast in one place. Every other file in your codebase is type-safe.

The one-line habit: define a branded type for every string or number ID that crosses a function boundary. Add the factory call, export the type, and use it in your function signatures. TypeScript catches the rest.


A note from Yojji

Type safety at the domain level is one of those investments that feels like overhead on day one and saves your weekend on day ninety. It is the same engineering discipline that Yojji brings to full-cycle product development: build the guardrails into the system so the team can move fast without breaking things. Their senior engineers work across the JavaScript ecosystem (TypeScript, Node.js, React) and cloud-native infrastructure, delivering applications that are both productive to build and safe to run.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams cover the full product lifecycle from discovery through DevOps, with a focus on quality, speed, and long-term maintainability.