解析,而非验证——在并不鼓励你这样做的语言中

Hacker News Top 工具

摘要

一篇探讨在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 文章的时候。

相似文章

关于“Parse, don't validate”的Rust思考

Lobsters Hottest

Eli Bendersky 将‘Parse, don't validate’模式应用于 Rust,展示如何使用类型系统来强制不变量并提高代码清晰度,使用标准库和像 nonempty 这样的 crates 中的示例。

将引用稳定性变为一种类型

Hacker News Top

一篇关于使用幻影品牌将引用稳定性编码为 TypeScript 类型的博客文章,从而使 React props 能够强制要求数组、回调函数和对象在多次渲染中保持稳定。

Parser Combinators

Hacker News Top

一篇技术博客,讲解如何用 TypeScript 中的函数式 Parser Combinator 模式(如 pSeq、pMap)组合小解析器来解析复杂文本,例如把 ping 输出转换为结构化 JSON 对象。

解析C语言中类型推断声明之险

Lobsters Hottest

这篇博文探讨了C23中涉及`auto`作为类型推断说明符或存储类说明符的解析歧义,展示了当`x`是typedef时,GCC和Clang在解析如`auto x = 67;`这样的声明上的分歧,以及属性如何使情况复杂化。