首页 / 文章 / TypeScript中的带标记ID:哪些编码能真正防止误删

TypeScript中的带标记ID:哪些编码能真正防止误删

在同一测试中对比了六种输入 UserId 和 InvoiceId 的方式:哪些会导致 tsc 拒绝执行 deleteInvoice(userId),以及 Zod 是如何在运行时提供安全保障的。

2472 词

想象一个名为 deleteInvoice 的辅助函数,其第一个参数应是发票编号,但调用时却传入了用户编号。如果 tsc 以退出码 0 结束运行,那么无论你声明了何种编号类型,都只是文档说明而已,而文档从来无法阻止那些具有破坏性的查询。本指南针对六种常见的 UserId 和 InvoiceId 编码方式进行了简单实验,展示了哪种方式能让编译器拒绝错误的调用,并最终提出了实用的策略:应在何处添加标记、在运行时如何进行验证,以及如何发现那些悄悄破坏一切的类型转换。

为何两个字符串别名属于同一类型

TypeScript 的类型系统是结构化的。形状相同的两种对象类型可以互相替换,而 string 的两个别名根本不算两种不同的类型——它们只是同名下的同一个 string。类型检查器没有依据来区分它们。

“品牌”机制通过为类型添加一个虚拟属性来解决这个问题。该属性在运行时并不存在,它的存在只是为了让类型检查器将 UserId 和 InvoiceId 视为不同的结构。交叉品牌、unique symbol 品牌、模板字面量前缀以及 Zod 的 .brand() 方法都是这一技巧的不同变体。

正是由于这种混淆,有两个常被误认为是“品牌”的运算符也被纳入了比较之中:satisfies 和 as const。这两种运算符都不会创建出独立的类型。

请始终记住一个事实:一旦 TypeScript 被剥离,所有的编码结果都只是普通字符串。Node 完全不知道这些类型的存在。你所能获得的唯一保护就是编译时检查器所施加的约束,以及你自己额外添加的运行时验证机制。

测试:一次非法调用

每种编码方式都会遇到相同的调用场景。某个函数期望接收 InvoiceId 类型的参数,但实际上传入的是被定义为 UserId 类型的值,问题就在于编译器是否会对此提出异议。

declare function deleteInvoice(id: InvoiceId): Promise<void>;
const userId = getUserId(); // UserId
await deleteInvoice(userId);

1. 普通类型别名

大多数代码库都是从这种形式开始的。

type UserId = string;
type InvoiceId = string;

这样的代码可以成功编译,且错误的类型标识也会消失。由于这两个名称最终都对应 string 类型,检查器便没有理由提出异议。这就是其余方案试图解决的基准问题。

2. 使用 as const 的字面量类型

此处,用户ID为字面量,而发票ID则为带有必填前缀的模板字面量类型。

const userId = "usr_123" as const;
type InvoiceId = `inv_${string}`;

这种情况仅适用于极少数情形。如果userId确实为字面量类型"usr_123",则无法将其赋值给`inv_${string}`,从而导致调用被拒绝。但实际上,ID通常来自函数、请求和数据库,像getUserId()这样的获取器一般会返回string类型。一旦出现这种情况,就又回到了第一种选择。对于已经为string类型的变量再添加as const并不会使其变成更有用的类型。还需注意,真正起作用的是模板字面量类型,而非as const。

3. 满足string类型要求

这种模式常作为安全措施出现在代码审查中。

const userId = getUserId() satisfies string;

该代码可以编译通过。satisfies用于验证表达式是否符合某种类型,同时保留该表达式本身推断出的类型;它不会引入新的命名类型。这是一个很有用的操作符,更像是拼写检查工具而非类型标识符,且无法防止传递错误的id。

4. 交集类型标识符

将string与带有只读__brand字段的对象进行交集操作,可使每个id都具有独特的结构。

type UserId = string & { readonly __brand: "UserId" };
type InvoiceId = string & { readonly __brand: "InvoiceId" };

现在tsc会拒绝接受deleteInvoice(userId)这种写法。这是完全不依赖任何库的实现方式。其代价是原始字符串不再适用,因此每个类型标识符都需要一个构造函数,将经过验证的字符串转换为对应的类型标识符:

function asUserId(raw: string): UserId {
  if (!raw.startsWith("usr_")) throw new Error("not a user id");
  return raw as UserId;
}

构造函数中的那个as操作是不可避免的缺陷。如果构造函数是公开的且不进行任何检查,它就会变成一个用于添加错误标签的工具。当编号确实带有前缀时,验证前缀是一种合理的检查方式。但如果编号是没有任何前缀的UUID,则不应为了实现这种检查而改变存储格式;而应根据数值的实际特征进行验证,比如UUID的格式或该编号确实是从发票表中读取的事实。

5. 唯一符号品牌

品牌键并非字符串形式的属性,而是一个仅声明一次的唯一符号。

declare const invoiceBrand: unique symbol;
type InvoiceId = string & { [invoiceBrand]: true };

编译器会像选项4那样拒绝这种错误的调用方式。由于该符号仅在一个模块中声明,其他文件很难通过创建具有相同键名的对象类型来伪造该标识。其代价是可读性:在拉取请求中,这种模式需要更多的解释说明,而__brand版本则无需。

6. Zod标识符

Zod能够为其推断出的类型附加标识符,而且与上述所有选项不同,它还能在运行时检查该类型的值。

const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
type InvoiceId = z.infer<typeof InvoiceId>;

使用 Zod 标准的 UserId 进行调用时会失败类型检查,而通过发票模式解析 usr_123 也会在运行时失败,因为 startsWith("inv_") 规则会拒绝该值。这种运行时检查是纯静态编码无法提供的:即使来自不可信来源且已被错误标记的值,在通过 parse 处理时也会被捕获。如果在其他地方手动进行 as InvoiceId 转换,仍然会完全绕过 Zod 的验证,因此这种保护仅适用于真正经过该模式处理的值。

评分标准

  • 普通别名:可以编译,但错误的删除操作仍会通过。
  • as const:只要源值被定义为 string 类型即可编译。
  • satisfies string:可以编译。
  • 交叉品牌类型:会被 tsc 拒绝。
  • unique symbol 类型:被 tsc 拒绝。
  • Zod 类型:被 tsc 拒绝,而且原始用户 ID 也会在运行时被 parse 拒绝。
  • 换句话说,人们通常认为可用于输入 ID 的那三种方式对解决此漏洞毫无作用,而真正有效的三种类型在正确使用的情况下则能阻止该问题。

    在自己的项目中重现此对比情况

    将这六种编码方式放入类似 src/ids.ts 的文件中,为每种编码添加非法的 deleteInvoice(userId) 调用,然后运行编译器但不输出任何结果:

    pnpm exec tsc --noEmit
    

    接着取一个用户 ID,并通过 Zod 模式对其进行验证,模拟从请求中传入的被窃取或混淆的值:

    InvoiceId.parse(String(userId));
    

    如果该解析成功,说明该类型仅是一个没有附加条件判断的标签而已。

    不要通过在定义旁边写上 id as InvoiceId 来测试品牌机制。类型转换始终能够通过编译,因此这样的测试无法证明任何问题。

    找出那些已经在损害你系统的类型转换

    品牌机制的强度取决于绕过它的地方有多少。请查找直接的类型转换:

    rg "as InvoiceId|as UserId" src app
    

    如果出现大量此类转换,说明该品牌机制大多只是装饰性存在。在引入更多带品牌的类型之前,先修复构造函数和边界解析逻辑。

    检查工具能保证什么,不能保证什么

    品牌机制实际上只是虚拟存在:生成的 JavaScript 代码仍然是 string 类型。只有当某个值从未经过 as InvoiceId 转换,也从未通过接受普通 string 类型并直接返回品牌值而不进行任何检查的函数时,检查工具才能在调用点保护你。

    satisfies仍然是评论中最常见的错误类型。它对于自身的功能而言是个不错的工具,但并不适合作为普通类型使用。

    像`inv_${string}`这样的模板字面量类型在行为上与普通类型有些相似,且还有优点在于能在类型本身中记录前缀信息。不过当标识符是没有前缀的UUID时,这类类型就会失效。应当让类型适应数据,而非让数据库去适应类型。

    在实践中表现良好的划分方式是:将公共边界处的类型定义为 Zod 品牌,而应用内部的类型定义为交叉品牌。在数据进入时(例如在请求处理程序中)进行一次解析,让带有品牌的类型承担起验证功能。在如 /invoices 这样的路由与后台工作进程之间每次传递数据时都重新解析只会增加成本。关于如何集中管理这一边界,可参阅使用一个 Zod 中间件保护 Express 边界。

    品牌化的成本

    • 构造函数。每个交叉品牌都需要一个构造函数。两种 ID 类型只需两个小型函数,而非二十个。
    • 错误的自信感。在 JSON.parse 之后直接添加一个 as InvoiceId,就会悄无声息地取消对后续所有数据的保护。
  • 运行时解析。Zod同时承担验证和类型标注的功能,每次数据进入时会进行一次解析操作并产生相应成本。在公共接口处这种成本是值得的,但一旦数据已经过检查,在内部传输时则往往过于繁琐。在高频访问路径上,需衡量其带来的成本。
  • 收益所在。一个经过编译的非法调用就可能导致某行数据丢失且无法恢复。相比之下,tsc编译失败不会产生任何成本,而这正是该虚拟字段的全部价值所在。
  • 真实的故障场景及有效的解决方案

    假设存在一个内部支持工具,其中UserId和InvoiceId都被定义为type X = string。用于显示用户信息的页面在其URL中包含用户ID,该页面上的删除操作会从URL中读取该ID并传递给deleteInvoice函数。虽然代码可以编译通过,但删除的却是用户记录而非发票。

    一种诱人的解决办法是重新命名参数以使意图更明确,但这并无帮助:下一个粗心大意的调用方同样能让代码顺利编译。

    真正有效的解决办法是在应用程序内部使用交叉类型和验证构造函数来进行类型标注:

    type InvoiceId = string & { readonly __brand: "InvoiceId" };
    function asInvoiceId(raw: string): InvoiceId {
      if (!raw.startsWith("inv_")) throw new Error("not an invoice id");
      return raw as InvoiceId;
    }
    

    同时在HTTP接口处使用Zod模式来实现验证与类型标注:

    const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
    

    修改之后,发票行上的删除按钮通过asInvoiceId从实际存储发票ID的字段中获取该ID。由于该页面是针对用户设计的,因此可以在其URL中保留用户ID。合适的类型定义本可以避免使用原有的辅助函数,更清晰的标签也能起到同样的作用,但团队两者都没有。

    最后的合理性检查:在源代码中搜索as InvoiceId应几乎找不到结果,而所有出现的实例都应有合理的存在依据。

    使检查可重复执行

    一个简短且可重复的验证流程能防止这些结果沦为无稽之谈。首先记录工具版本,因为不同主要版本之间的行为可能会有所差异。本次比较的参考环境是一个基于 Node 24、TypeScript 7 和 Next.js 16.3 的小型四路由发票应用;在对比结果之前,请先检查自己项目中的版本信息。

    node -v
    pnpm exec tsc -v
    pnpm exec next --version
    

    如果实际版本与预期不符,在轻信后续结果之前请先暂停操作。然后启动应用并测试相关路由:

    pnpm exec next dev
    

    在开发工具中开启日志记录功能,访问 /、/invoices、/invoices/1、/settings 以及再次访问 /invoices,这样就能查看每个页面的 URL 中实际包含的标识符。

    最后,以适合脚本运行的方式运行类型检查器,并查看其退出状态:

    pnpm exec tsc --noEmit --pretty false
    echo $?
    

    退出码为零并不能证明产品是正确的。这只是说明编译阶段没有发现任何问题,仍需检查运行时的表现。此外,建议在版本信息和输出结果旁为每次失败尝试单独写一行(如“尝试了X,但仍然出现Y”),这样后续人员就不会重复犯错。

    品牌常遇到的失败原因

    • JSON.parse之后直接使用as InvoiceId:这会让品牌标识变成纯粹的装饰。
    • 在审查中将satisfies string当作品牌标识来使用:它仅能检测一致性。
    • UUID列前加上模板字面量前缀,随后又有人为了使类型匹配而将此前缀添加到存储的数据中。应撤销这种做法,直接对解析后的值进行品牌标识处理,而非修改数据库以适应某种类型。
  • 从 barrel 文件导出的构造函数,如 asInvoiceId,使得任何希望跳过验证的模块都能轻松使用它。
  • 调用带类型标识的 id 之前的检查清单

    • 它未被声明为 type FooId = string。
    • satisfies string 并非其唯一的验证条件。
    • 存在构造函数或 Zod 解析机制在边界处对其进行保护。
    • deleteInvoice(userId) 会导致 tsc 报错。
    • 通过 as InvoiceId 搜索得到的结果应是一个数量有限且可合理解释的列表。

    作用域也很重要。无需为仓库中的每个字符串都添加标识。只需为那些可能破坏或泄露数据的 id 添加标识:删除、退款和身份冒充相关的路径是较好的候选对象。如果最终添加了五十个标识,那只是做表面处理而非真正的保护。

    一组简洁的命令即可完成各项检查,其中包括对剩余的纯字符串标识符别名的查找:

    pnpm exec tsc --noEmit
    rg "as InvoiceId" src
    rg "type \w+Id = string" src
    

    非法的 deleteInvoice(userId) 调用应出现在预期会通过类型检查失败的测试文件中(可在其上方添加 @ts-expect-error 注解),绝不能出现在像 lib/delete.ts 这样的生产代码中。

    在您的代码库中尝试

    在您实际使用的删除辅助函数旁边,写上非法的 deleteInvoice(userId) 调用语句,放在编译器会检查的位置。如果 tsc 没有报错,说明这些标识符只是注释而已。将 InvoiceId 转换为交叉类型,并确认该调用会显示为红色警告。在 HTTP 接口处添加验证构造函数或 Zod 类型校验,确保原始的 “usr_123” 字符串会引发错误。之后查找所有 as InvoiceId 的用法,要么在拉取请求中为每处用法提供合理解释,要么将其删除。

    关键要点

    • 别名、as const 和 satisfies 无法创建独立的类型,因此不能防止标识符混淆的问题。
    • 交叉类型和 unique symbol 能让编译器拒绝错误的调用;而 Zod 类型校验则会对通过 parse 处理的数值进行运行时验证。
  • 每个框架在 as 中都提供了应急方案。将类型转换操作限制在小型验证构造函数中,其余部分则需进行审查。
  • 在边界处一次性完成解析与标记操作,将静态标记传递到内部,并仅对那些误用会导致不可逆损害的标识符进行特殊标记。