Menu

TypeScript 品牌类型(Branded Types):名义类型示例

品牌类型是带有隐形标签的原始类型,例如 string & { readonly __brand: "UserId" },这样在需要 OrderId 的地方就无法传入 UserId。本文讲解品牌的工作原理、负责校验的构造函数、通用的 Brand 辅助类型、unique symbol 品牌以及品牌化的数字。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

TypeScript 按形状比较类型,所以 string 的两个别名可以互换。品牌类型添加了一个只存在于类型系统中的标签 string & { readonly __brand: "UserId" },它让 UserId 既不兼容普通字符串,也不兼容其他任何品牌:

运行时 userId 只是字符串 "u_42"。品牌是编译期的标签,它唯一的作用就是防止 ID、单位和已校验的字符串被混用。

问题所在:别名只是名字

类型别名不会创建新类型。它只是给已有类型起了第二个名字,编译器把两个名字当成同一种东西:

这就是结构类型:TypeScript 检查的是形状是否匹配,而 string 当然匹配 string。对象的形状通常各不相同;但 ID、邮箱、货币和单位的形状从来没有区别。品牌类型解决的正是这一种情况。

品牌的工作原理

string & { readonly __brand: "UserId" } 是一个交叉类型:值必须是字符串,同时还要有一个类型为 "UserId" 的 __brand 属性。真正的字符串都没有这个属性,所以普通字符串无法赋值给它:

index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
  Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
    Types of property '__brand' are incompatible.
      Type '"OrderId"' is not assignable to type '"UserId"'.

真正重要的那个方向依然可行:UserId 是字符串,所以你可以把它传给任何接受字符串的地方,对它调用 .startsWith(),或者把它放进模板字符串。品牌只拦住了“进来”的路。

负责校验的构造函数

as UserId 断言是唯一的入口,而断言什么也不检查。把它放进一个校验输入的函数里,程序中每个品牌值就都确定通过了这项检查:

sendWelcome 不再重复检查输入,因为它的参数类型已经说明检查做过了。这就是“parse, don't validate”(解析而非校验)的思路:在边界处检查,然后让类型携带这份证明。如果你更喜欢返回布尔值而不是抛出异常,类型守卫同样可以充当构造函数:function isEmail(s: string): s is Email。

通用的 Brand 辅助类型

为每个类型手写交叉类型很快就会变得重复。一个小小的泛型就能一次搞定:

给数字加品牌和给字符串加品牌的方式完全一样。注意 amount / 100 是普通的 number:对品牌化数字做算术运算,得到的是没有品牌的结果,下文会讲到。

unique symbol 品牌

像 __brand 这样的字符串属性名看起来像真实属性:userId.__brand 的类型检查结果是 "UserId",运行时却是 undefined,而且两个库可能会选用同一个名字。用 unique symbol 作为键可以同时避免这两个问题:

declare const brand: unique symbol 声明了一个只为类型检查器而存在的 symbol;declare 关键字表示不会为它生成任何 JavaScript。由于这个 symbol 没有从所在模块导出,其他文件中的代码连品牌属性的名字都写不出来,所以在模块之外,得到 Meters 的唯一途径就是你导出的函数或 as Meters 断言。

品牌在运行时零开销

编译输出中找不到品牌的任何痕迹。下面是 Meters 示例生成的代码,位于编译器添加的模块头部之后:declare、两个类型别名和所有 as 都消失了,最后一行被抑制了报错的调用依然会执行。

function toMeters(feet) {
    return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);

品牌值就是普通的原始值:typeof 得到 "string" 或 "number",JSON.stringify 照常输出,比较运算也和以前一样。反过来说,除非你的构造函数做了检查,否则运行时什么都不会检查。从 JSON、数据库或 URL 解析出来的数据都是 string,只有经过那个函数之后才会变成 UserId。

算术运算和方法会丢掉品牌

对品牌值进行运算返回的是基础类型,因为品牌不属于 + 或 .slice() 的结果:

type Cents = number & { readonly __brand: "Cents" };

const a = 500 as Cents;
const b = 250 as Cents;

const sum = a + b;           // number, not Cents
const total: Cents = a + b;  // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid

这通常正是你想要的:两笔以分为单位的金额相加还是分,但分乘以分就不是了,只有你知道哪些运算能保持原来的含义。为代码需要的运算写一些小的辅助函数,比如 addCents(a: Cents, b: Cents): Cents。

何时使用品牌类型

当同一原始类型的两个值确实有混用风险,而编译器又帮不上忙时,就该使用品牌:

场景品牌示例
来自不同表的 IDUserId、OrderId、ProductId
已校验的字符串Email、Url、NonEmptyString、Slug
单位和货币Meters、Feet、Cents、Usd、Eur
已清理或已转义的文本SafeHtml、SqlIdentifier
有取值范围的数字Percentage、PositiveInt

对于从不会被混淆的值,以及形状本来就不同的对象类型,就不必使用品牌。校验库可以根据 schema 生成品牌类型:在 Zod 中,z.string().brand<"UserId">() 得到的 schema,其 parse 会返回品牌化的 UserId,省去了手写构造函数的工作。

常见问题

TypeScript 中的品牌类型是什么?

一种让运行时表示相同的两个类型互不兼容的写法。你把基础类型和一个普通值都不具备的标签做交叉:type UserId = string & { readonly __brand: "UserId" }。这样一来,在需要 UserId 的地方,普通字符串或带有不同标签的 OrderId 都会被拒绝。

TypeScript 有名义类型吗?

没有。TypeScript 的类型系统是结构化的:形状相同的两个类型可以互换,不管它们叫什么名字。带有 private 或 #private 成员的类声明表现为名义类型,而品牌类型是让字符串、数字这类原始类型获得同样效果的常用方法。

品牌类型有运行时开销吗?

没有。品牌只存在于类型中。运行时值依然是普通的字符串或数字,没有任何额外属性,编译出的 JavaScript 和不加品牌时完全一样。唯一的运行时代码,是你自己选择放在创建品牌值的函数里的校验逻辑。

如何创建品牌类型的值?

用类型断言,最好放在一个先检查输入的小函数里:function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }。把 as 只留在这一处,就意味着程序中每个 Email 都通过了检查。

类型别名和品牌类型有什么区别?

type UserId = string 只是一个新名字:在需要 UserId 的地方,任何字符串都能传入。type UserId = string & { readonly __brand: "UserId" } 则是一个新的、不兼容的类型:普通字符串必须先经过构造函数或断言。

Coddy 编程语言插图

和 Coddy 一起学编程

开始