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

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 文章的时候。

相似文章

类型检查的非空字符串

Hacker News Top

本文分享了一种使用 GHC 的 RequiredTypeArguments 进行类型检查的非空字符串的 Haskell 技术,实现了编译时验证,并在大型代码库中获得了约 10% 的构建时间改进。

C语言中无法解析整数 (2022)

Hacker News Top

文章批评了C标准库中用于解析整数的函数(atol、strtol、strtoul、sscanf),解释了为什么大部分函数存在缺陷,只有strtol在仔细进行错误处理的情况下才能正确使用。