解析,而非验证——在并不鼓励你这样做的语言中
摘要
一篇探讨在TypeScript中应用“解析,而非验证”原则的博客文章,展示了如何使用品牌类型(branded types)在解析后保留类型信息,尽管TypeScript的结构类型系统使得这种做法不如在Elm或Haskell等语言中那样自然。
暂无内容
查看缓存全文
缓存时间: 2026/06/30 12:36
# 解析,而非验证——在一个并不鼓励你这样做语言中
来源: https://cekrem.github.io/posts/parse-dont-validate-typescript/
**更新:** 如果你喜欢这篇文章,后续文章——[Effect Without Effect-TS: Algebraic Thinking in Plain TypeScript](https://cekrem.github.io/posts/effect-without-effect-ts/)——将从这里继续,并将这些想法进一步推进。
我一直在思考 Alexis King 的 [Parse, don't validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) 这篇文章。实际上,我经常这样做,通常是在盯着一个 TypeScript 代码库,看到那些像藤壶一样悄悄累积的 `if (user.email)` 检查之后。那篇文章发表于 2019 年,而这个建议(或者说原则)远比那更古老。然而,我读到的大部分 TypeScript 代码——包括尴尬的是,我自己写过的许多——仍然在做验证而非解析。
如果你还没读过那篇文章(应该读一下),它的核心观点是:验证器说“这个东西没问题,请继续。” 解析器说“给我一个原始数据,我要么还给你一个更精确的类型,要么告诉你为什么做不到。” 这种区别听起来像是学术讨论,直到你意识到验证器在运行完成后就会丢弃信息,而解析器则通过将所学信息编码到类型中来*保留*它。一旦你将一个字符串解析成了 `EmailAddress`,程序的其余部分就再也不需要怀疑了。安心,并且把更多心智容量留给了有趣的事情。
在 Haskell、Elm 或 F# 中,这就是你写代码的方式。语言会把你拉向它。在 TypeScript 中……它不会。TypeScript 乐意让你做正确的事情,但它不会坚持,甚至不会轻轻推你一下。而且,结构类型系统实际上在暗中破坏整个游戏。让我来展示我的意思。
## 我们都写过的验证器
(链接到标题: https://cekrem.github.io/posts/parse-dont-validate-typescript/#the-validator-weve-all-written)
下面是我经常看到(以及写)的代码:
```typescript
interface User {
id: number;
email: string;
age: number;
}
// 实际的验证是天真且简单的,但你明白意思:
function isValidUser(user: User): boolean {
if (!user.email.includes("@")) return false;
if (user.age < 0 || user.age > 150) return false;
return true;
}
function sendWelcome(user: User) {
if (!isValidUser(user)) {
throw new Error("invalid user");
}
// ...稍后,在调用栈更深的地方:
emailService.send(user.email, `Welcome, age ${user.age}`);
}
```
发现漏洞了吗?`User.email` 只是 `string`。`User.age` 只是 `number`。验证发生了——恭喜——但类型系统在 `isValidUser` 返回的那一刻就忘记了一切。三个函数调用之后,当有人触碰 `user.email` 时,没有任何东西阻止他们将其传递给一个期望真实电子邮件的函数。因为对 TypeScript 来说,它只是一个字符串。和 `""`、`"hello"`、`"definitely not an email"` 一样。
那我们怎么办?我们重新验证。我们再加一个 `if`。我们写一个单元测试。我们祈祷。
(King 在原博客文章中对这种做法有一个更贴切的词:“散弹式解析”——验证散落在各处,没有一处被记住。)
## 我们真正想要的
(链接到标题: https://cekrem.github.io/posts/parse-dont-validate-typescript/#what-we-actually-want)
我们想要这样:
```typescript
function sendWelcome(user: ValidUser) {
emailService.send(user.email, `Welcome, age ${user.age}`);
}
```
而且我们希望*不可能*用没有经过解析器处理的东西来调用 `sendWelcome`。不需要重新检查或“防御性编程”。类型本身作为证明,就像那样。在 Elm 中,我会使用一个不透明类型和一个智能构造函数,大约四行就搞定了。在 TypeScript 中,嗯,至少是*可能*的。只是不那么愉快。
## 品牌类型,或者说:故意欺骗结构类型系统
(链接到标题: https://cekrem.github.io/posts/parse-dont-validate-typescript/#branded-types-or-lying-to-the-structural-type-system-on-purpose)
TypeScript 是结构类型系统,这意味着两个形状相同的类型就是相同的类型。`string` 就是 `string` 就是 `string`。没有 `newtype`。没有像 Haskell 那样产生真正不同类型的 `type EmailAddress = String`。
社区采取的变通方法是*品牌化*——也称为*标记*,或通过交集实现的名义类型。廉价版本是一个字符串字面量的幻影(`{ readonly __brand: "Email" }`),你会到处看到它;稍微不那么廉价的版本使用一个你不从模块导出的 `unique symbol`,这样外部任何人都无法*拼写*出这个品牌来伪造它:
```typescript
declare const EmailBrand: unique symbol;
declare const AgeBrand: unique symbol;
type Email = string & { readonly [EmailBrand]: true };
type Age = number & { readonly [AgeBrand]: true };
```
没有运行时存在的品牌字段。它是一个“幻影”——一个类型级别的标记,使得 `Email` 和 `string` 在编译时互不兼容。获得 `Email` 的唯一途径是通过一个知道如何操作的函数,因为这个模块之外的任何东西都无法命名那个符号来伪造一个。(TS5 还允许你尝试模板字面量类型——`type Email = \`${string}@${string}\``——这在演示中很有趣,但单独使用还不够。)
这一步让你能够使非法状态不可表示,同时不离开这门语言。品牌是单向的,顺便一提:一个 `Email` 仍然可以赋值给 `string`。名义类型*进入*领域,结构类型离开,这几乎完全是你想要的。那个函数就是你的解析器:
```typescript
type ParseError = { kind: "ParseError"; message: string };
type Parsed<T> = { kind: "ok"; value: T } | { kind: "err"; error: ParseError };
function parseEmail(raw: string): Parsed<Email> {
if (!raw.includes("@")) {
return { kind: "err", error: { kind: "ParseError", message: "missing @" } };
}
// 我们已经检查过了,现在故意欺骗类型系统
return { kind: "ok", value: raw as Email };
}
function parseAge(raw: unknown): Parsed<Age> {
if (
typeof raw !== "number" ||
!Number.isInteger(raw) ||
raw < 0 ||
raw > 150
) {
return { kind: "err", error: { kind: "ParseError", message: "bad age" } };
}
return { kind: "ok", value: raw as Age };
}
```
(`parseEmail` 的谓词简单得令人尴尬——真正的实现会修剪、小写,并且至少假装验证域名部分。然而,我不会在博客文章中写一个电子邮件解析器!)
`as Email` 有点疼,也应该疼。这是允许我们违反规则的地方——解析器是受信任的边界。在代码库的其他任何地方,你不能从一个 `string` 中变出一个 `Email`。你必须调用 `parseEmail` 并处理两个分支。
(我故意使用 `kind: "ok" | "err"` 而不是布尔判别式。布尔值看起来整洁,直到有人添加第三种情况,而穷尽性检查不会无声触发。字符串可以诚实地进行窄化。)
比较一下我们开始时那个“抛出并祈祷”的验证器:它的失败模式是一个异常,对类型系统不可见。解析器的签名告诉你所有可能发生的事情。在调用栈中没有隐藏的第三个选项。
现在来看领域类型。我想命名两个通常被混淆的东西:从网络上拿到的原始数据,和我已经赢得信任的产物。
```typescript
declare const UserIdBrand: unique symbol;
type UserId = number & { readonly [UserIdBrand]: true };
type UnvalidatedUser = {
id: unknown;
email: unknown;
age: unknown;
};
type ValidUser = {
readonly id: UserId;
readonly email: Email;
readonly age: Age;
};
function parseUserId(raw: unknown): Parsed<UserId> {
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
return { kind: "err", error: { kind: "ParseError", message: "bad id" } };
}
return { kind: "ok", value: raw as UserId };
}
function parseUser(raw: unknown): Parsed<ValidUser> {
if (typeof raw !== "object" || raw === null) {
return { kind: "err", error: { kind: "ParseError", message: "not an object" } };
}
if (!("id" in raw) || !("email" in raw) || !("age" in raw)) {
return { kind: "err", error: { kind: "ParseError", message: "missing fields" } };
}
if (typeof raw.email !== "string") {
return { kind: "err", error: { kind: "ParseError", message: "email not a string" } };
}
const id = parseUserId(raw.id);
if (id.kind === "err") return id;
const email = parseEmail(raw.email);
if (email.kind === "err") return email;
const age = parseAge(raw.age);
if (age.kind === "err") return age;
return { kind: "ok", value: { id: id.value, email: email.value, age: age.value } };
}
```
将 `UnvalidatedUser` 与 `ValidUser` 分开命名是一个很小的 DDD 手法,它自己就能证明价值:原始数据进入,信任数据出来,边界是一个函数。`id` 也被品牌化了——领域中的每个基本类型都是一次错过的对话,而一个 `UserId` 不能被传递到期望 `OrderId` 的地方,是整个技术中成本最低的胜利之一。
(再也没有 `as Record` 了;如果我写一篇关于不对类型系统撒谎的文章,那我就不应该对类型系统撒谎。)
这远不如 F# 或 Elm 的等价物美观。我不会假装不是这样。错误时提前返回的模式是 TypeScript 在不引入库的情况下最接近 `Result` 单子的东西,而且它变得重复。(你*可以*使用 [Effect](https://effect.website/)、[neverthrow](https://github.com/supermacro/neverthrow) 或 fp-ts 来清理它,对于任何比玩具大的项目我都会这么做。但我想展示语言开箱即用给你的东西,因为即使语法不理想,原则仍然成立。)
回报是 `sendWelcome(user: ValidUser)` 现在真正安全了。你的代码库中没有任何路径可以不经过 `parseUser` 就产生一个 `ValidUser`。类型*就是*证明。验证没有被丢弃。
## TypeScript 与你作对的地方
(链接到标题: https://cekrem.github.io/posts/parse-dont-validate-typescript/#where-typescript-fights-you)
有几件事仍然令人恼火。
第一个是 `parseEmail` 内部的 `as Email` 类型断言。在一个真正的名义类型语言中,智能构造函数不需要撒谎——它返回新类型是因为新类型真的是不同的。在 TypeScript 中,品牌是虚构的,所以你不得不做出断言来绕过它。这需要的纪律是:*只有解析器才被允许做那个断言*。如果断言泄漏到代码库的其他地方,整个方案就会崩溃。我习惯于将解析器放在它们自己的模块中,并将任何模块外的 `as Brand<...>` 视为一个 bug。(自定义 ESLint 规则有帮助。)
第二个是穷尽性检查。可辨识联合是 TypeScript 对这种风格的关键特性——它们是语言最接近 Elm 自定义类型的东西——而语言确实通过 `never` 窄化提供了穷尽性检查;但它缺少一个专用的 `match` 表达式,所以你必须手动编写 `never` 技巧并记住编写它:
```typescript
function describe(result: Parsed<ValidUser>): string {
switch (result.kind) {
case "ok":
return `user ${result.value.id}`;
case "err":
return `failed: ${result.error.message}`;
default: {
const _exhaustive: never = result;
return _exhaustive;
}
}
}
```
向 `Parsed` 添加第三种变体后,`never` 赋值会失败,编译器会准确地告诉你哪里需要检查。相比之下,在 Elm 中,忘记一个分支是你根本无法忽略的编译错误。
(另外提一下:`satisfies` 是另一个值得了解的现代逃生出口——`const x = { ... } satisfies Config` 在不拓宽类型的情况下检查是否符合类型,这样你就保留了精确的字面量类型,同时获得了安全性。它是类型断言的优雅版本。)
第三个令人恼火的地方是 `JSON.parse`。它返回 `any`,这是语言中最糟糕的类型,也是这篇博文存在的全部原因。立即将其标注为 `unknown`——`const raw: unknown = JSON.parse(input)`——然后让解析器接手。`JSON.parse` 不是验证器的邪恶表亲;它是一个反序列化器。它将字节转换为 JS 值。那个值是不是一个 `User` 是一个完全独立的问题,正是你的解析器存在的理由。
## Zod 怎么样?
(链接到标题: https://cekrem.github.io/posts/parse-dont-validate-typescript/#what-about-zod)
Zod 很棒。io-ts 也是。valibot 也是。使用它们。它们是我刚才写的一切的符合人体工程学的版本——一个模式优先的 DSL,从同一个定义中给你一个解析器和一个 TypeScript 类型:
```typescript
import { z } from "zod";
const ValidUserSchema = z.object({
id: z.number().int(),
email: z.string().email().brand<"Email">(),
age: z.number().int().min(0).max(150).brand<"Age">(),
});
type ValidUser = z.infer<typeof ValidUserSchema>;
const result = ValidUserSchema.safeParse(rawInput);
```
`safeParse` 返回 `{ success: true, data }` 或 `{ success: false, error }`——与我上面构建的形状相同,只是字段名不同。`.brand()` 调用完全是类型级别的,与手工编写的符号技巧完全相同;运行时什么也不会发生。你得到的是来自一个定义的解析器和类型,这从结构上强制了我在前几节要求你手工执行的解析器/类型共置边界。仅此一点就值得这个依赖。
但是——这是我反复强调的部分——Zod 并没有改变*思维模式问题*。它只是让正确的事情更容易。你仍然必须在每个边界处选择使用它。你仍然必须抵制使用类型断言来绕过错误消息的诱惑。你仍然必须记住,来自网络的 `User` 在某个东西解析它之前并不是一个 `User`。库是一个工具。纪律是你自己的。
(我在 [Why TypeScript Won't Save You](https://cekrem.github.io/posts/why-typescript-wont-save-you/) 中简要提到了这一点,同样的观点:语言不会强制边界,所以你必须自己强制。)
## 更小的原则
(链接到标题: https://cekrem.github.io/posts/parse-dont-validate-typescript/#the-smaller-principle)
如果我必须把 King 的想法压缩成一个我在发布前晚上11点能记住的句子,那就是:*让类型系统承载证明,而不是你的记忆*。每次你检查某些东西却没有将结果编码到类型中时,你就是在要求未来的自己记住。未来的你不会记住。未来的他正在调试另一个 bug,只睡了三个小时,并且会假设验证已经发生,因为当然发生了,看看所有这些 `if` 语句。
验证器会泄漏。解析器不会。
在 TypeScript 中,这意味着依赖语言*确实*给你的三样东西,即使它给得很勉强:用于近似名义类型的品牌类型、用于诚实错误处理的可辨识联合,以及 `unknown`(来自外部的东西)和你的领域类型(你已赢得信任的东西)之间的严格边界。
这些都不如 Elm 干净。但都比替代方案好。
我有时仍然写验证器。我不会假装我重构了接触到的每一个代码库,使其变成解析管道——那会是谎言,而且可能也是时间的浪费。但是,当我发现在三个不同的文件中添加第三个防御性的 `if`,都在检查同一件事时,我知道发生了什么。我本应该解析,却验证了。信息在那里。只是不在类型里。
这通常是我回去再读一遍 King 文章的时候。
相似文章
一种类型化的代数解析方法
一篇介绍类型化代数解析方法的论文,很可能来自剑桥大学。
类型检查的非空字符串
本文分享了一种使用 GHC 的 RequiredTypeArguments 进行类型检查的非空字符串的 Haskell 技术,实现了编译时验证,并在大型代码库中获得了约 10% 的构建时间改进。
新Blub悖论,或者说:为什么TypeScript在AI时代是一个糟糕的选择
认为TypeScript由于不健全的类型系统而成为AI时代的一个糟糕默认选项,它无法捕获AI生成的代码中的错误,并将其比作Paul Graham的Blub悖论。
@boshen_c: 是时候揭晓这项工作的幕后人物了!@Shanshrew 创建了一种新型解析器架构,速度提升 2-3 倍。Pl…
由 Shanshrew 开发的新型解析器架构比当前最快的 JS/TS 解析器快 2-3 倍,并正在集成到 Oxc 中。
C语言中无法解析整数 (2022)
文章批评了C标准库中用于解析整数的函数(atol、strtol、strtoul、sscanf),解释了为什么大部分函数存在缺陷,只有strtol在仔细进行错误处理的情况下才能正确使用。