Featured image of post TypeScript 品牌类型:防止原始混乱Featured image of post TypeScript 品牌类型:防止原始混乱

TypeScript 品牌类型:防止原始混乱

深入研究用于名义打字模拟的 TypeScript 品牌类型:品牌模式、交叉类型、验证函数以及 ID、货币和电子邮件的用例。

TypeScript 使用结构类型(鸭子类型):具有相同形状的两种类型可以互换。当基元类型返回域概念时,这会导致错误 - 在需要 orderId 的地方传递 userId ,两者都键入为 string 。考虑一个发送电子邮件但意外收到数据库 ID 而不是地址的函数,因为两者都是 string。品牌类型通过添加幻像类型标记来解决这个问题,该标记在编译时以零运行时成本区分结构相同的类型。

品牌格局

核心技术使用具有幻影品牌属性的交叉类型:

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

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;

__brand 属性在运行时永远不存在——它在编译期间被删除。 TypeScript 将 UserIdOrderId 视为不同的类型,因为它们的品牌不同。

function getUser(id: UserId): User { /* ... */ }
function getOrder(id: OrderId): Order { /* ... */ }

const uid = "user_123" as UserId;
const oid = "ord_456" as OrderId;

getUser(uid); // OK
getUser(oid); // Type error: 'OrderId' is not assignable to 'UserId'

使用 declare 或将品牌属性设置为可选以避免运行时分配问题:

// Better: unique symbol brand prevents accidental casting
declare const UserIdBrand: unique symbol;
type UserId = string & { [UserIdBrand]: true };

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

类型保护和验证函数

由于品牌在运行时不存在,因此必须在系统边界(API 端点、数据库结果和表单提交)对值进行验证和品牌化。

// Factory function with validation
function createUserId(value: string): UserId {
  if (!/^user_[a-f0-9]{24}$/.test(value)) {
    throw new Error(`Invalid UserId format: ${value}`);
  }
  return value as UserId;
}

// Type guard
function isUserId(value: string): value is UserId {
  return /^user_[a-f0-9]{24}$/.test(value);
}

// Assertion function
function assertUserId(value: string): asserts value is UserId {
  if (!isUserId(value)) {
    throw new Error(`Expected a valid UserId, got: ${value}`);
  }
}

为复杂类型编写验证器并处理反序列化:

interface User {
  id: UserId;
  email: Email;
  name: string;
}

function parseUser(data: unknown): User {
  const raw = data as Record<string, unknown>;
  return {
    id: createUserId(String(raw.id)),
    email: createEmail(String(raw.email)),
    name: String(raw.name),
  };
}

域原语的用例

品牌类型在原始混乱造成真正损害的领域大放异彩。

域名品牌类型预防
电子商务UserIdOrderIdProductId交叉 ID 分配错误
金融科技USDJPYEUR货币算术错误
医疗保健PatientIdProviderIdClaimId患者之间的数据泄露
SaaSApiKeySessionToken凭证滥用

货币处理是一个引人注目的例子:

type USD = Brand<number, "USD">;
type JPY = Brand<number, "JPY">;

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

function addJPY(a: JPY, b: JPY): JPY {
  return (a + b) as JPY;
}

const price: USD = 29.99 as USD;
const tax: USD = 3.00 as USD;
const yenAmount: JPY = 5000 as JPY;

addUSD(price, tax);       // OK
addUSD(price, yenAmount); // Type error: 'JPY' not assignable to 'USD'

通用品牌类型和库

为常见品牌模式创建通用实用程序:

type Entity<T extends string> = Brand<string, T>;

interface Repository<T, TId extends string> {
  findById(id: Entity<TId>): Promise<T>;
  save(entity: T): Promise<void>;
}

// Usage with Zod for runtime validation + branding
import { z } from "zod";

const UserIdSchema = z.string().brand("UserId");
type UserId = z.infer<typeof UserIdSchema>;

const userRepo: Repository<User, "UserId"> = {
  async findById(id) {
    // Type-safe database query
    return db.query("SELECT * FROM users WHERE id = $1", [id]);
  },
  async save(user) { /* ... */ },
};
图书馆品牌推广方法运行时成本
占位符_0占位符_1
占位符_0品牌编解码器仅验证
占位符_0.brand() 方法仅验证
手册交叉口类型

性能和迁移

品牌类型的运行时开销为零——品牌属性在编译期间被删除。验证函数确实会增加运行时成本,因此在系统边界验证一次并通过内部代码库传播品牌类型。

// Validate at boundary, propagate internally
async function handleRequest(rawId: string) {
  const userId = createUserId(rawId); // Validate once
  const user = await userRepo.findById(userId);
  await sendEmail(user.email, welcomeMessage);
  // userId and user.email are branded — type-safe throughout
}

增量迁移:从最关键的域原语(ID、货币金额)开始,在模块边界添加品牌类型,并使用 lint 规则强制执行。从新代码开始或一次重构一个模块,而不是一揽子迁移。

品牌类型提供零成本的编译时安全性,通过类型传达域含义来提高代码可读性,并防止整个类别的错误。安全方面的好处远远超过了冗长的代价。将它们集成到您的系统边界,并让类型检查器保护您的域逻辑。