Branded Types + 幽灵类型:编译期阻止非法操作
第13章:Branded Types + 幽灵类型:编译期阻止非法操作
理解Branded Types + 幽灵类型是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用编译期阻止非法操作?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 问题根源
- Branded Types:基础实现
- 安全工厂函数
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/catch 的 catch 子句永远是 unknown,错误类型在函数签名里完全隐形。Result<T, E> 模式把错误变成返回值,让编译器强迫你处理每一种失败情况。