几乎每个前端团队都吵过这个问题:后端返回的数据,前端要不要做「防御」?
一派主张 防御性编程 :后端不可信,接口随时会变,字段可能为 null、可能漏传、可能类型对不上,所以前端要对每一层数据判空、兜底、容错,宁可写得啰嗦,也不能让页面白屏。
另一派主张 信任契约 :接口有文档、有 TypeScript 类型定义,前后端是约定好的协作方。前端照着接口定义写类型,靠编译期类型检查就够了,运行时再堆一堆 ?. 和 || 是过度设计,污染代码、降低可读性。
两边都有道理,也都有反例。本文的结论先放在前面:
这是一个被错误地表述为「二选一」的问题。正确答案是分层------在「信任边界」上做运行时校验,在边界之内信任类型。
下面一点点拆开讲,并落到 Zod / Valibot / io-ts 的真实数据、axios 与 fetch 的可运行示例,以及一段关于「这套方案到底会不会增加工作量」的诚实讨论。
一、先想清楚:TypeScript 的类型,到底保障了什么?
很多人对 TS 有一个根本性误解,以为:
ts
interface User {
id: number
name: string
vip: boolean
}
const res = (await fetch('/api/user').then(r => r.json())) as User
写了 as User,res.name 就一定是 string。
这是错的。 TS 的类型系统是 编译期 的,编译产物里类型信息会被全部擦除(type erasure)。as User 只是你对编译器的一句「承诺」,它不会在运行时去验证后端到底返回了什么。
也就是说,data 在类型层面看起来 100% 是 User,但运行时它可能是:
null(后端异常返回了空){ code: 500, message: 'error' }(错误结构没被拦截就透传进来了){ id: "123", name: null }(字段类型和你以为的不一样)
类型断言(as)是这场争论里最大的「假安全感」来源。 它让你以为自己处在「信任契约」的安全区,实际上你既没有运行时防御,也没有真正的类型保障------只是骗了编译器,也骗了自己。
所以争论的真正命题不该是「信任 vs 防御」,而是:
数据从「不可信的外部世界」进入「可信的应用内部」的那一刻,你做了什么?
二、信任边界(Trust Boundary):问题的核心
借用安全领域的一个概念------信任边界。
你的应用内部是一个「可信区域」:这里的数据结构由你自己构造和控制,类型即真理。而网络的另一端------后端接口、localStorage、URL 参数、postMessage、第三方 SDK------都是「不可信区域」:数据的实际形状不由你掌控。
两派的错误,在于把策略一刀切地铺满整个应用:
-
纯防御派 的问题:在「可信区域」里也到处
?.、|| ''、if (data && data.list && data.list.length)。数据明明已经进入内部、类型已经确定,却还在反复防御。结果是业务逻辑被噪音淹没,更糟的是 防御本身会掩盖 bug ------user?.name ?? '未知'让一个本不该为空的字段静默兜底,问题被藏起来,等到线上数据错乱时根本查不到源头。 -
纯信任派 的问题:在「信任边界」上直接
as,把不可信数据强行标记为可信。一旦后端真的改了字段、漏传了数据,错误会穿透边界、扩散到应用深处 ,最终在某个离数据源十万八千里的组件里抛出Cannot read property 'xxx' of undefined,排查成本极高。
正确的做法是:把所有防御集中到信任边界这一条线上,边界之内则完全信任类型。
三、落地方案:在边界做运行时校验(Schema Validation)
边界校验的最佳实践,是用 运行时 schema 校验库 替代裸 as。目前社区事实标准是 Zod。
核心思路:让「运行时校验」和「编译期类型」从同一份 schema 派生,做到 single source of truth。
这不是某个博主的个人发挥,而是 Zod 官方文档开宗明义的设计目标。Zod 的 README 原文是:
"With Zod, you declare a validator once and Zod will automatically infer the static TypeScript type." (用 Zod,你只声明一次校验器,Zod 会自动推导出静态 TypeScript 类型。) ------ Zod 官方 README
ts
import { z } from 'zod'
// 1. 定义一次 schema
const UserSchema = z.object({
id: z.number(),
name: z.string(),
vip: z.boolean(),
avatar: z.string().nullable(), // 明确声明:这个字段可能为 null
})
// 2. 类型从 schema 自动推导,不用再手写 interface
type User = z.infer<typeof UserSchema>
// => { id: number; name: string; vip: boolean; avatar: string | null }
// 3. 在边界处「校验」,而不是「断言」
async function getUser(): Promise<User> {
const raw = await fetch('/api/user').then(r => r.json())
return UserSchema.parse(raw) // 不符合 schema 直接抛错,错误就地暴露
}
这套方案同时满足了两派的核心诉求:
| 诉求 | 是否满足 | 怎么做到的 |
|---|---|---|
| 信任派:边界之内靠类型,不写防御代码 | 满足 | parse 之后返回的就是确定的 User,内部代码放心 user.name |
| 防御派:后端变了不能白屏 | 满足 | parse 失败时在边界抛错,可统一捕获、上报、降级 |
| 不要假安全感 | 满足 | 用真实的运行时校验替代 as 的「假装」 |
| single source of truth | 满足 | 类型和校验逻辑同源,接口改了只改一处 |
关键在于:防御只发生一次,在边界上。 进了门之后,user.name 就是 string,你不需要、也不应该再写 user?.name ?? ''。
一个容易踩的坑:当 schema 带
transform(如把字符串转数字)时,输入和输出类型不同。z.infer返回的是 输出类型 ,需要区分时用z.input和z.output。这一点 Zod 文档有明确说明。
四、两种主流请求库的边界写法
无论用 fetch 还是 axios,思路都一样:把校验收敛到请求层,业务层只拿到「已经可信」的数据。
4.1 fetch:封装一个带校验的 request
ts
import { z, type ZodType } from 'zod'
async function request<T>(url: string, schema: ZodType<T>, init?: RequestInit): Promise<T> {
const res = await fetch(url, init)
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const raw = await res.json()
// 信任边界就在这一行:校验通过返回 T,失败抛 ZodError
return schema.parse(raw)
}
// 业务侧调用:拿到的就是确定的类型,无需再防御
const UserSchema = z.object({ id: z.number(), name: z.string() })
const user = await request('/api/user', UserSchema)
4.2 axios:用拦截器统一在边界校验
axios 的 interceptor 天然就是「边界」。一种常见做法是把 schema 透传给一个薄封装:
ts
import axios, { type AxiosRequestConfig } from 'axios'
import { z, type ZodType } from 'zod'
const http = axios.create({ baseURL: '/api', timeout: 10_000 })
// 统一在响应拦截器里处理后端业务包裹(如 { code, data, message })
http.interceptors.response.use((response) => {
const body = response.data
if (body.code !== 0) {
return Promise.reject(new Error(body.message ?? '业务错误'))
}
response.data = body.data // 剥掉外层信封,只把 data 往下传
return response
})
// 薄封装:在边界对 data 做 schema 校验
async function get<T>(url: string, schema: ZodType<T>, config?: AxiosRequestConfig): Promise<T> {
const { data } = await http.get(url, config)
return schema.parse(data)
}
// 业务侧:依然只拿到「已可信」的类型
const UserSchema = z.object({ id: z.number(), name: z.string() })
const user = await get('/user', UserSchema)
注意:拦截器里剥离
{ code, data, message }信封属于结构约定 ,schema 校验的是剥离后的业务数据。两件事职责不同,别混在一起写。
五、那「防御」就完全不要了吗?按数据重要性分级
校验失败之后怎么办,要按接口的「重要程度」决定降级策略------这才是防御性思维真正该发力的地方:不是判空,而是设计失败时的行为。
1. 核心主链路数据 :校验失败 = 整个页面没有意义。直接 fail fast,展示错误页 + 上报监控。绝不静默兜底------给用户看一个金额错误的订单,比报错更危险。
2. 非核心模块数据 :校验失败不该拖垮整个页面。用 safeParse + 默认值让这个模块优雅降级,其余页面照常。
ts
const result = RecommendSchema.safeParse(raw)
const list = result.success ? result.data : [] // 局部降级,不影响主页面
if (!result.success) reportError(result.error) // 但要上报,别让问题消失
3. 低风险配置数据:给合理默认值,校验失败走默认行为即可。
注意第二、三点和「纯防御派」的区别:我们依然在边界集中处理 ,依然上报错误 ,而不是在业务代码里到处撒 ?. 把问题悄悄吞掉。
六、工具选型:Zod、Valibot、io-ts 到底选哪个?
这三个都是「从一份定义同时产出运行时校验 + 静态类型」的库,但定位差异很大。下面的数据均来自官方文档与公开基准。
| 维度 | Zod | Valibot | io-ts |
|---|---|---|---|
| 定位 | 社区事实标准、生态最大 | 体积敏感场景(边缘函数 / 浏览器) | 函数式(FP)风格的前辈 |
| API 风格 | 链式方法(z.string().min(1)) |
模块化函数(可 tree-shaking) | 基于 fp-ts 的 Either monad |
| 体积(登录表单 schema,gzip) | v4 约 5kB / v3 约 12kB / Zod Mini 约 3.94--6.88kB | 约 1.37kB | 需额外引入 fp-ts |
| 类型推导 | z.infer |
v.InferOutput |
t.TypeOf |
| 生态 | tRPC 原生、react-hook-form(zodResolver)等 |
增长中,已支持 Standard Schema | 较窄,社区已普遍转向 Zod |
| 维护状态 | 活跃,周下载量约 1500 万 | 活跃,v1.0(2025) | 仍维护(最新 2.2.22,2024-12),但热度下降 |
数据来源:Zod 官方文档、Valibot 对比指南、gcanti/io-ts。
选型建议
- 默认选 Zod。 生态最成熟、文档最完整、
z.infer是被 tRPC、react-hook-form 等大量项目验证过的范式。绝大多数 Web 应用,5kB(v4)体积完全可以接受。 - 体积敏感选 Valibot。 如果你在写 Cloudflare Workers、边缘函数,或对首屏 bundle 极度敏感,Valibot 的模块化设计能让校验逻辑只「按用量计费」。官方对比中,同一个登录表单 schema:Zod 标准版约 17.7kB(esbuild),Valibot 仅 1.37kB,约 90% 的体积削减;即使对比专为体积优化的 Zod Mini(约 3.94kB),Valibot 仍小约 3--5 倍。
- io-ts 一般不再作为新项目首选。 它是 runtime 类型校验的重要先驱,思路(codec = 运行时类型 + 静态类型)和今天的 Zod 一脉相承。但它依赖
fp-ts、用Eithermonad 表达成功/失败,学习曲线更陡。连一些早期 io-ts 的推广者都已公开改推 Zod。除非你的团队本就深度使用 fp-ts 函数式栈,否则没必要。
一个值得知道的趋势:Zod v4 与 Valibot 都实现了 Standard Schema 规范,这意味着上层工具(表单库、tRPC 等)可以用统一接口对接不同校验库,选型不再像过去那样「一锁到底」。
七、诚实讨论:这套方案会不会增加额外工作量?
会。任何工程方案都有成本,回避成本的「银弹叙事」本身就不可信。下面把代价摊开讲清楚。
7.1 真实存在的成本
- 包体积。 多引入一个校验库。Zod v4 约 5kB(v3 约 12kB),对体积敏感的项目是实打实的负担。
- 运行时开销。 每个响应都要
parse,会消耗 CPU。对超大列表 / 高频接口,这部分开销需要评估(虽然通常远小于网络耗时)。 - schema 维护成本(最大的一项)。 后端接口一变,schema 就得跟着改。如果纯手写、又有几百个接口,这是一笔持续的人力开销。
7.2 缓解手段
这些成本大多有成熟解法,不必硬扛:
- 不要全量校验,只在边界 + 关键接口校验。 核心主链路(支付、详情)严格校验;低风险接口可以宽松甚至跳过。成本随重要性分配。
- schema 自动生成,而不是手写。 如果后端有 OpenAPI / Swagger,可以用
orval、openapi-zod-client等工具直接生成 Zod schema 和请求函数。schema 维护成本从「手写」降到「重新生成」。这是把工作量问题工程化掉的关键一步。 - 体积敏感就换 Valibot 或 Zod Mini。 如前一节所述,体积可以压到 1--4kB 量级。
- 类型从 schema 派生,避免「类型 + 校验」两份脱钩。 用
z.infer而不是「手写 interface + 手动as」------后者表面省事,实则埋下「类型与真实数据不一致」的雷。
7.3 什么时候「不值得」做
诚实地说,下面这些场景,引入 schema 校验可能是过度工程:
- 一次性脚本、原型、Demo。 活不过一周的代码,不需要为「后端某天会变」付费。
- 前后端在同一个 monorepo、共享同一份类型、且有端到端类型管线(如 tRPC、GraphQL Code Generator)。 此时「契约」已经由工具链在编译期保障,运行时再校验一遍收益有限------但注意,这要求那条管线真的覆盖了运行时边界,而不只是「两边 import 了同一个 interface」。
- 内部低风险工具、用户量极小、出错代价低。 投入产出不成正比。
一句话总结这一节:「在边界用 Zod 校验」是成熟可靠的主流实践,但它是有成本的工程决策,不是无脑默认。把校验按数据重要性分配、用 OpenAPI 生成 schema、按体积需求选库,能把成本压到合理区间。
八、可以直接抄进团队规范的几条原则
- 禁止用
as断言外部数据。as只骗编译器。外部数据一律走 schema 校验。 - 类型从 schema 派生,而不是手写 interface + 手动
as。 一份 schema 同时产出类型和校验,避免两者脱钩。 - 防御集中在信任边界(请求层 / 数据适配层),业务层信任类型。 不要在组件里写防御逻辑。
- 校验失败必须可观测。 fail fast 或降级都行,但一定要上报,禁止
?? ''这类「让 bug 消失」的兜底。 - 降级策略按数据重要性分级。 核心数据 fail fast,非核心数据局部降级。
?./||用来表达「这个字段在业务上本就可选」,而不是用来「防后端」。 如果一个字段在 schema 里是 required,业务代码里就不该对它判空------那是在用防御掩盖契约违背。- 能自动生成就别手写 schema。 后端有 OpenAPI,就用工具生成,把维护成本工程化掉。
九、结论
回到最初的问题:防御性编程,还是信任契约?
既不是不信任后端写满兜底,也不是盲目信任后端裸
as。而是:在数据进入应用的边界上做一次严格的运行时校验,校验通过后,边界内部完全信任类型。
- 「纯防御」错在把防御铺满全场,用噪音掩盖 bug;
- 「纯信任」错在把
as当成保障,用假安全感放任错误穿透; - 真正成熟的做法是 「边界校验 + 内部信任」:用 Zod(或体积敏感时用 Valibot)让运行时校验和编译期类型同源,把防御收敛到一条线上,再按数据重要性设计失败降级,并用 OpenAPI 生成等手段把工作量控制在合理范围。
Trust, but verify at the boundary------信任,但要在门口验票。 这句话基本可以概括整个工程实践。
参考与数据源
- Zod 官方文档 / README ------
z.infer单一数据源范式、与 io-ts / Joi / Yup 的对比 - Zod 官网
- Valibot 对比指南(含 vs Zod 体积数据)
- Valibot 官网
- gcanti/io-ts 与 io-ts 文档
- Standard Schema 规范
- orval(从 OpenAPI 生成 Zod + 请求代码)
- 一位早期 io-ts 使用者公开改推 Zod 的复盘:Kieran Hunt, How I use io-ts...