一篇题为《Parse, Don't Validate — In a Language That Doesn't Want You To》的 TypeScript 文章近日重新把一个老原则推到台前:不要只验证数据,要把数据解析成更可信的领域类型。作者引用的是 Alexis King 2019 年那篇《Parse, don’t validate》,但问题并不旧。很多 TypeScript 项目至今仍在函数深处散落 if (user.email)、if (age > 0) 这类检查。
这件事真正重要的地方,不是开发者又多学一个“类型体操”,而是接口边界的责任重新被说清了。validator 返回 true 或 false;parser 返回更精确的类型或错误。前者检查发生了,但类型系统很快忘掉;后者把“已经检查过”这件事编码进类型,后续业务代码才少靠记忆和约定。
Validator 的问题:检查做了,类型没记住
TypeScript 是结构类型语言。Email、UserId、Age 如果最后都只是 string 或 number,领域含义会被抹平。一个普通字符串能不能当邮箱,不取决于变量名,而取决于它有没有穿过可信边界。
文章给出的主张是,用 branded type 模拟一种“弱名义类型”。例如 Email = string & { readonly [EmailBrand]: true }。这里的 brand 不是运行时真实字段,主要是类型层标记;TypeScript 也没有因此变成真正的 nominal typing 语言。这是一种纪律化变通,好处是让普通 string 不能随手传进需要 Email 的函数。
| 做法 | 返回结果 | 类型系统是否保存结果 | 典型风险 |
|---|---|---|---|
| validator | true / false | 通常不会 | 业务层反复补 if,异常路径隐形 |
| parser | 精确类型 / 错误 | 会 | 需要集中处理错误,写法更啰嗦 |
| Zod safeParse | data / error | 可与类型推导配合 | 边界不调用,仍然失效 |
可行路径:品牌类型、解析函数和 Result 返回
文章最实用的建议,是把 as 断言关进解析函数。解析函数先检查原始值,成功后才允许 raw as Email,并返回类似 { kind: 'ok', value } | { kind: 'err', error } 的判别联合。业务代码只接收 ValidUser,而不是接收一个“可能已经校验过”的 User。
这会影响两类团队。后端和全栈开发者在处理 HTTP body、消息队列、Webhook、数据库反序列化时,应把外部输入先视为 unknown。负责领域建模的工程师则要区分 UnvalidatedUser 和 ValidUser,否则 UserId、OrderId 混传这类低级错误很难靠 code review 稳定挡住。
代价也真实存在。TypeScript 没有 Elm、F# 那样顺手的 opaque type 或模式匹配,手写 Result 风格会重复。never 穷尽检查、satisfies、branded type 都能补一部分,但都依赖团队规范。这个原则适合边界清晰、数据质量成本高的项目;对一次性脚本或内部小工具,可能显得过重。
Zod 能省力,但不能替团队守门
Zod、io-ts、valibot 的价值,是把 schema、运行时解析和 TypeScript 类型放得更近。Zod 的 safeParse 就是 parser,而 .brand() 也主要是类型层标记。它改善的是 ergonomics,不是自动消灭类型风险。
原文没有展开但工程上很关键的一点是:JSON.parse 返回 any,这会绕开 TypeScript 最基本的防线。更稳妥的做法是立刻收口为 unknown,例如 const raw: unknown = JSON.parse(input),再交给 parser 或 schema 处理。数据库驱动、第三方 SDK、环境变量读取也是同类边界。
接下来真正该观察的,不是哪一个库更流行,而是团队能否把规则落到代码库:brand 的断言是否只出现在 parser 模块;API handler 是否统一先 parse;ESLint 或代码审查是否阻止业务代码里到处 as Email。如果断言扩散,branded type 只会变成另一种自我安慰。
