第 13 章

Branded Types + 幽灵类型:编译期阻止非法操作

第13章:Branded Types + 幽灵类型:编译期阻止非法操作

理解Branded Types + 幽灵类型是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用编译期阻止非法操作?关键的设计决策和陷阱是什么?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

问题根源

TypeScript 的类型系统是结构性的(structural):两个类型只要形状相同,就互相兼容。这对大多数场景是好事,但对于"不同概念恰好有相同形状"的情况,结构类型会让编译器袖手旁观。

type UserId = string;
type PostId = string;
type OrderId = string;

function getPost(authorId: UserId, postId: PostId): Post {
  return db.posts.findOne({ author: authorId, id: postId });
}

const userId: UserId  = "user_001";
const postId: PostId  = "post_042";

getPost(postId, userId); // 参数全反了,编译器:没问题

三个类型别名,三个都是 string,在 TypeScript 眼里完全相同。Branded Types 通过向结构里注入一个编译期标记,打破这种等价关系。


Branded Types:基础实现

核心思路:在 string 交叉类型里加入一个永远不会存在于运行时、仅供编译器识别的字段。

// 基础 brand 模式
type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };
type OrderId = string & { readonly __brand: "OrderId" };

// 现在这三个类型不再互相兼容
declare const userId: UserId;
declare const postId: PostId;

const x: UserId = postId; // 编译错误:Type 'PostId' is not assignable to type 'UserId'

__brand 字段在运行时根本不存在——交叉类型的这部分只活在 TypeScript 的类型检查层面,不影响任何运行时行为,也不增加内存开销。


安全工厂函数

直接用类型断言 "abc" as UserId 可以创建 branded 值,但这样等于绕过了保护。正确做法是用工厂函数,在工厂里做验证。

// 通用 Branded 工具类型
type Brand<T, B extends string> = T & { readonly __brand: B };

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

// 工厂函数:验证 + 创建
function makeUserId(raw: string): UserId {
  if (!raw.startsWith("user_")) {
    throw new Error(`Invalid UserId format: "${raw}"`);
  }
  return raw as UserId; // 唯一合法的类型断言位置
}

function validateEmail(raw: string): Email {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(raw)) {
    throw new Error(`Invalid email: "${raw}"`);
  }
  return raw as Email;
}

function parseUUID(raw: string): UUID {
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
  if (!uuidRegex.test(raw)) {
    throw new Error(`Invalid UUID: "${raw}"`);
  }
  return raw as UUID;
}

// 使用方:只能用工厂函数获得 branded 值
const userId = makeUserId("user_001");   // UserId — 已验证
const email  = validateEmail("[email protected]"); // Email  — 已验证

// 受保护的函数
function sendEmail(to: Email, subject: string): void {
  mailer.send(to, subject);
}

sendEmail("[email protected]", "spam"); // 编译错误:string 不是 Email
sendEmail(email, "Welcome!");          // OK

真实场景:已验证数据流

Branded Types 最大的价值在于描述数据的"验证状态"——区分用户原始输入(可能有任何内容)和经过验证的干净数据。

type RawInput    = string;
type ValidEmail  = Brand<string, "ValidEmail">;
type NormalizedEmail = Brand<string, "NormalizedEmail">;

// 验证层:只有验证通过才能得到 ValidEmail
function validateEmailInput(input: RawInput): ValidEmail | null {
  const trimmed = input.trim();
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return null;
  return trimmed as ValidEmail;
}

// 规范化层:把 ValidEmail 转成统一小写的 NormalizedEmail
function normalizeEmail(email: ValidEmail): NormalizedEmail {
  return email.toLowerCase() as NormalizedEmail;
}

// 存储层:只接受规范化后的邮箱
function saveUser(email: NormalizedEmail): Promise<void> {
  return db.users.insert({ email });
}

// 完整流程
async function registerUser(formInput: RawInput): Promise<void> {
  const valid = validateEmailInput(formInput);
  if (!valid) throw new Error("Invalid email");

  const normalized = normalizeEmail(valid);
  await saveUser(normalized);
}

// 以下调用全部报编译错误:
saveUser("[email protected]");     // string 不是 NormalizedEmail
saveUser(validateEmailInput("[email protected]")!); // ValidEmail 不是 NormalizedEmail

Level 2 · 它是怎么运行的(3-5年经验)

幽灵类型(Phantom Types)

幽灵类型是类型参数从不出现在运行时值的结构里,只存在于类型层面。它比 Branded Types 更灵活,可以表达状态机和权限模型。

// 基础幽灵类型模式
type Tagged<T, Tag> = T & { readonly __tag: Tag };

// 状态机示例:数据库连接
declare const _connectionBrand: unique symbol;
type Connection<State extends "open" | "closed" | "error"> = {
  readonly [_connectionBrand]: State;
  readonly id: string;
};

// 工厂函数:返回特定状态的连接
function openConnection(url: string): Connection<"open"> {
  const conn = createRawConnection(url);
  return conn as unknown as Connection<"open">;
}

function closeConnection(conn: Connection<"open">): Connection<"closed"> {
  conn.close(); // 假设的底层操作
  return conn as unknown as Connection<"closed">;
}

// 只接受 open 状态的连接
function query<T>(conn: Connection<"open">, sql: string): Promise<T> {
  return conn.execute(sql);
}

// 使用:状态流转由类型系统追踪
const conn = openConnection("postgres://localhost/mydb"); // Connection<"open">
await query(conn, "SELECT 1");                              // OK

const closed = closeConnection(conn);          // Connection<"closed">
await query(closed, "SELECT 1");               // 编译错误:Connection<"closed"> 不是 Connection<"open">

防止单位混淆

物理单位、货币单位的混淆是系统级 bug 的常见来源(著名案例:NASA 火星气候轨道器因英制/公制混淆损失 3.27 亿美元)。

// 单位 branded types
type Meters = Brand<number, "Meters">;
type Feet   = Brand<number, "Feet">;
type Kilograms = Brand<number, "Kilograms">;
type Pounds = Brand<number, "Pounds">;

const meters = (n: number): Meters => n as Meters;
const feet   = (n: number): Feet   => n as Feet;

function calculateArea(width: Meters, height: Meters): Meters {
  return (width * height) as Meters;
}

const w = meters(5);
const h = feet(10);

calculateArea(w, h); // 编译错误:Feet 不是 Meters
calculateArea(w, meters(10)); // OK: 50 平方米

货币类型:真实项目案例

电商系统里,美元金额和欧元金额都是 number,但混用会造成财务错误。

// 货币 branded 类型系统
type Currency = "USD" | "EUR" | "CNY" | "GBP";

type Money<C extends Currency> = {
  readonly amount: number;
  readonly currency: C;
  readonly __brand: C; // phantom tag
};

function money<C extends Currency>(amount: number, currency: C): Money<C> {
  if (amount < 0) throw new Error("Amount cannot be negative");
  return { amount, currency, __brand: currency };
}

// 加法只允许同币种
function addMoney<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
  return money(a.amount + b.amount, a.currency);
}

// 汇率转换:明确标识为不同货币
function convertUSDtoEUR(usd: Money<"USD">, rate: number): Money<"EUR"> {
  return money(usd.amount * rate, "EUR");
}

// 显示价格:接受任意货币
function formatMoney<C extends Currency>(m: Money<C>): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: m.currency,
  }).format(m.amount);
}

// 实际使用
const price = money(99.99, "USD");   // Money<"USD">
const tax   = money(8.00,  "USD");   // Money<"USD">
const total = addMoney(price, tax);  // Money<"USD"> — OK

const eurPrice = money(89.99, "EUR"); // Money<"EUR">
addMoney(total, eurPrice);            // 编译错误:Money<"EUR"> 不是 Money<"USD">

const eurEquivalent = convertUSDtoEUR(total, 0.92); // Money<"EUR"> — 明确换汇
console.log(formatMoney(total));          // "$107.99"
console.log(formatMoney(eurEquivalent));  // "€99.35"

结合 Result 类型的完整验证流程

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

type RawUserId  = string;
type ValidUserId = Brand<string, "ValidUserId">;

function parseUserId(raw: RawUserId): Result<ValidUserId, string> {
  if (!raw.match(/^user_[a-z0-9]{6,}$/)) {
    return { ok: false, error: `Invalid user ID format: "${raw}"` };
  }
  return { ok: true, value: raw as ValidUserId };
}

async function getUser(rawId: RawUserId): Promise<Result<User, string>> {
  const idResult = parseUserId(rawId);
  if (!idResult.ok) return idResult;

  const user = await db.users.findById(idResult.value); // ValidUserId
  if (!user) return { ok: false, error: `User not found: ${rawId}` };
  return { ok: true, value: user };
}

Level 3 · 规范怎么定义的(资深)

Branded Types 利用了 TypeScript 交叉类型的语义:string & { readonly __brand: "UserId" } 在运行时仍然是 string,但在类型系统中变成了一个独特类型。__brand 属性永远不会在运行时被访问或创建。幽灵类型(Phantom Types)源自 Haskell,其核心思想是用类型参数携带编译期信息而不影响运行时表示。在 TypeScript 中通过 declare + unique symbol 可以创建更安全的幽灵标记。

Level 4 · 边界与陷阱(所有人)

反模式:过度 Brand 化

// 反例:每个字符串都加 brand,收益递减
type FirstName     = Brand<string, "FirstName">;
type LastName      = Brand<string, "LastName">;
type StreetAddress = Brand<string, "StreetAddress">;
type CityName      = Brand<string, "CityName">;
type ZipCode       = Brand<string, "ZipCode">;

// 问题:没有互换的风险,brand 只增加复杂度
function buildAddress(
  street: StreetAddress,
  city: CityName,
  zip: ZipCode
): string {
  return `${street}, ${city} ${zip}`; // 谁会把 city 传给 street 参数?
}
// 判断标准:只有当参数类型相同、且真实存在混淆风险时,才值得 brand
// 适合 brand:UserId / PostId / OrderId(都是 string,都是 ID,容易传错)
// 不适合:FirstName / LastName(很少有函数同时接受这两个参数)

汇总

技术 适用场景 运行时开销 复杂度
Branded Types 同类型不同概念 (ID, 货币)
Phantom Types 状态机、权限级别
验证状态 Brand Raw → Validated 数据流
货币类型 防止单位混淆 极小(对象)

下一章预告

第 14 章进入错误处理类型化try/catchcatch 子句永远是 unknown,错误类型在函数签名里完全隐形。Result<T, E> 模式把错误变成返回值,让编译器强迫你处理每一种失败情况。

本章评分
4.7  / 5  (23 评分)

💬 留言讨论