本节目标:读完这一节,你能说清
value is T类型守卫到底做了什么、又没做什么,能识别手写守卫在嵌套结构上的漏判与误判,能从零实现一个「schema 即唯一事实来源」的验证器,讲透parse与safeParse的分工、InferOutput类型推导的实现原理,并用穷尽性检查把校验缺口变成编译错误。
10.1 类型守卫与验证库原理
在 1.2 类型擦除与运行时边界 里我们已经确认过一条铁律:类型在运行时不存在。可现实世界的数据是存在的——它来自 HTTP 请求、配置文件、第三方接口、数据库驱动。这两件事之间的缝隙,就是本章要处理的对象。
前三节我们从编译器的角度看了类型如何被擦除、如何被优化;从这一节开始,我们换到运行时的视角,看看当数据真的流进程序时,类型安全还剩下多少。
类型断言:把问题推迟到运行时
几乎每个 TypeScript 项目里都躺着这样一行代码:
const user = JSON.parse(body) as User;
这行代码的类型检查完全通过,user.name.toUpperCase() 会给你自动补全,编译器一路绿灯。但它没有做任何检查——as 只是在告诉编译器「相信我」,而不是在验证。如果 body 是 {},那么 user.name 就是 undefined,.toUpperCase() 会在运行时报:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
这类错误的特征是:类型检查全绿,测试可能也全绿,只在生产环境的某条分支上炸掉。断言不是错误,滥用断言才是。as 适合用在「你比编译器知道得更多」的场景,比如刚被守卫收窄过、或者来自受信任的内部模块;它不适合用在信任边界的入口。
用户定义类型守卫:is 的声明本质
TypeScript 提供的第一个工具是用户定义类型守卫,语法是给返回类型写上 value is T:
function isUser(value: unknown): value is User {
return typeof value === "object" && value !== null && "id" in value;
}
关键认知是:value is User 是一句声明,不是一条指令。 编译器不会去验证你的函数体是否真的检查了 User 的所有字段,它只接受你的说法,并在返回 true 的分支里把类型收窄。也就是说,is 守卫的可靠性完全由函数体自己负责——它把「断言」从调用处搬到了定义处,仅此而已。
这个区别在协作里很重要:as 是每个调用点各自下注,而 is 守卫是集中下注一次,所有调用点共享。后者显然更好,但仍然可能出错。
守卫的三个陷阱
陷阱一:检查了存在,没检查类型。
const payload: unknown = { id: "abc", name: 42 };
if (isUser(payload)) {
payload.id.toFixed(2); // 💥 id 其实是 string
// TypeError: payload.id.toFixed is not a function
}
"id" in value 只保证属性存在,不保证它是 number。要修好,每个字段都得单独判定:
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return typeof v.id === "number" && typeof v.name === "string";
}
陷阱二:只检查了第一层。
type Order = { id: string; items: { sku: string; qty: number }[] };
function isOrder(value: unknown): value is Order {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return typeof v.id === "string" && Array.isArray(v.items);
}
Array.isArray(v.items) 对元素一无所知。items: [{ sku: 1 }] 会顺利通过守卫,然后在业务逻辑深处炸开。嵌套越深,手写守卫越容易漏。
陷阱三:守卫函数被复用在不成立的场景。
isUser 检查的是「形状」,不是「语义」。一个 { id: 1, name: "x" } 的对象可能来自被删除的旧数据、可能来自攻击者构造的请求。形状合法 ≠ 数据合法。
从守卫到 schema:把校验变成数据
手写守卫的根问题是:校验逻辑是代码,而代码无法被复用、组合、推导或生成文档。要检查一个对象数组,你得写三个函数并手工保证它们同步。于是验证库的思路出现了——把校验规则从「函数」变成「数据」:
// 伪代码:schema 是描述,不是函数
const UserSchema = object({
id: number(),
name: string(),
tags: array(string()),
});
type User = Infer<typeof UserSchema>; // 类型从 schema 推导,而不是手写
这一步的收益是决定性的:schema 成了唯一事实来源,类型、运行时校验、错误信息、API 文档、表单生成全部从它派生。手写守卫做不到这一点,因为类型和校验是两份独立的声明,必然漂移。
自己实现一个最小验证器
要理解验证库,最好的方式是写一个。我们从结果类型开始,明确区分「成功」与「失败」:
type Result<T> = { ok: true; value: T } | { ok: false; issues: string[] };
class ValidationError extends Error {
constructor(readonly issues: string[]) {
super(issues.join("; "));
this.name = "ValidationError";
}
}
然后定义 Validator<T>。注意它同时提供两件事:运行时校验与编译期类型携带。后者靠一个只存在于类型层的幽灵字段完成:
class Validator<T> {
// 幽灵字段:运行时不存在,只用于让 T 出现在类型参数里
declare readonly _output: T;
constructor(private readonly check: (input: unknown) => Result<T>) {}
parse(input: unknown): T {
const r = this.check(input);
if (!r.ok) throw new ValidationError(r.issues);
return r.value;
}
safeParse(input: unknown): Result<T> {
return this.check(input);
}
// 让 Validator 自己也能当守卫用
is(input: unknown): input is T {
return this.check(input).ok;
}
}
declare readonly _output: T 是这里最关键的一行。declare 修饰符告诉编译器「这个字段只存在于类型里,不要生成任何运行时代码」,于是 Validator<T> 在运行时只是一个带 check 的普通对象,但类型系统里它携带了 T。
接着是组合子:
const string = () =>
new Validator<string>((input) =>
typeof input === "string" ? { ok: true, value: input } : { ok: false, issues: ["期望 string"] },
);
const number = () =>
new Validator<number>((input) =>
typeof input === "number" && Number.isFinite(input)
? { ok: true, value: input }
: { ok: false, issues: ["期望 number"] },
);
const array = <T>(item: Validator<T>) =>
new Validator<T[]>((input) => {
if (!Array.isArray(input)) return { ok: false, issues: ["期望数组"] };
const out: T[] = [];
const issues: string[] = [];
input.forEach((el, i) => {
const r = item.safeParse(el);
if (r.ok) out.push(r.value);
else issues.push(`[${i}] ${r.issues.join(", ")}`);
});
return issues.length ? { ok: false, issues } : { ok: true, value: out };
});
array 展示了两个工程要点:错误要带路径([2] 期望 number 比 期望 number 有用得多),失败时要收集所有问题而不是抛出第一个。
对象组合子稍复杂,因为它要处理「多余字段」这个语义选择:
type Shape = Record<string, Validator<any>>;
function object<S extends Shape>(
shape: S,
opts: { strip?: boolean } = {},
): Validator<{ [K in keyof S]: S[K] extends Validator<infer T> ? T : never }> {
return new Validator((input) => {
if (typeof input !== "object" || input === null) return { ok: false, issues: ["期望对象"] };
const src = input as Record<string, unknown>;
const out: Record<string, unknown> = {};
const issues: string[] = [];
for (const [key, validator] of Object.entries(shape)) {
const r = validator.safeParse(src[key]);
if (r.ok) out[key] = r.value;
else issues.push(`${key}: ${r.issues.join(", ")}`);
}
if (!opts.strip) {
for (const key of Object.keys(src)) {
if (!(key in shape)) issues.push(`${key}: 未知字段`);
}
}
return issues.length ? { ok: false, issues } : { ok: true, value: out as any };
});
}
strip 默认关闭意味着未知字段会被拒绝(严格模式)。这是安全上的正确默认值,理由我们在 10.3 会展开——但很多库(包括 Zod 的 object)默认是「剥掉多余字段」,这个差异必须在设计时明确。
类型推导:Infer 是怎么算出来的
现在到了最有意思的部分。我们要从 Validator<T> 反推出 T:
type Infer<V> = V extends Validator<infer T> ? T : never;
const UserSchema = object({
id: number(),
name: string(),
tags: array(string()),
});
type User = Infer<typeof UserSchema>;
// 推导结果:
// type User = { id: number; name: string; tags: string[] }
这条 infer 之所以成立,全靠前面那个幽灵字段 _output——没有它,Validator<T> 的 T 就是一个「只出现在构造函数参数里」的类型,无法从实例类型反推。验证库的类型推导能力,本质上是把泛型参数「钉」在实例类型上的一种技巧。
如果你不想用幽灵字段,也可以让 Validator 直接继承一个函数签名,但 infer 的写法会变得别扭。这也是为什么你在 Zod 的源码里会看到类似 _output、_input、_def 这样的下划线字段——它们都是给类型系统看的,不是给运行时用的。
Infer 与 object 的组合还隐含一个结论:schema 的结构直接决定了推导出的类型的结构。如果你在 object 里写错一个字段名,类型和运行时校验会一起错,不会各自漂移。这正是「唯一事实来源」在类型层面的体现。
parse 与 safeParse:异常还是结果
每个验证库都要面对这个选择,而正确答案是两个都给:
| 方法 | 失败行为 | 适用场景 |
|---|---|---|
parse(input) | 抛 ValidationError | 边界入口,失败即请求失败 |
safeParse(input) | 返回 Result | 需要合并错误、需要分支处理 |
is(input) | 返回布尔 | 用作类型守卫,嵌入 if |
选型的经验法则是:边界入口用 parse,内部逻辑用 safeParse。在 HTTP 处理器里,输入非法就应该立刻返回 400,抛异常并由统一错误中间件转换是最省事的;但在「批量导入,收集所有行的错误」这类场景里,抛异常会让你只看到第一行。
值得提醒的是 is 的语义。当它作为守卫嵌入 if (UserSchema.is(x)) 时,收窄是成立的;但要注意它和 parse 走的是同一份 check,如果你为了性能让 is 走一条更宽松的快速路径,两者就会不一致——这是真实项目里出现过的 bug。
结构化校验的成本与短路
验证器的性能主要花在递归下降上。一个深度为 5、元素数为 1000 的嵌套数组,check 会被调用上万次。两个优化点:
const array = <T>(item: Validator<T>) =>
new Validator<T[]>((input) => {
if (!Array.isArray(input)) return { ok: false, issues: ["期望数组"] };
const out: T[] = [];
for (let i = 0; i < input.length; i++) {
const r = item.safeParse(input[i]);
// 严格模式:遇到第一个错误立即返回,不继续遍历
if (!r.ok) return { ok: false, issues: [`[${i}] ${r.issues.join(", ")}`] };
out.push(r.value);
}
return { ok: true, value: out };
});
短路(fail-fast)还是收集全部错误,是一个必须显式做出的权衡:前者在大数组上快得多,后者对用户更友好。主流库的做法是提供 abortEarly 之类的开关,默认值各家不同。上面我们的 array 选择收集全部(便于调试),而生产环境的大批量导入通常选短路。
另一个常被忽视的成本是对象属性的访问。Object.entries(shape) 在每次校验时都会创建新数组,对热路径来说是纯开销。成熟库会把 shape 的键预先编译成闭包数组,用空间换时间——这也是「为什么验证库都建议把 schema 定义在模块顶层」的原因:它应该只被构造一次,而不是每次请求都重建。
穷尽性检查:让缺口变成编译错误
校验代码最容易出的问题不是写错,而是忘了写。当领域里新增一个状态时,散落各处的 switch 和守卫不会自动更新。用 never 可以把它变成编译错误:
type Status = "draft" | "published" | "archived";
function assertNever(value: never): never {
throw new Error(`未处理的状态: ${JSON.stringify(value)}`);
}
function label(status: Status): string {
switch (status) {
case "draft":
return "草稿";
case "published":
return "已发布";
// 故意漏掉 archived
default:
return assertNever(status);
// ❌ Argument of type '"archived"' is not assignable to parameter of type 'never'.
}
}
这个技巧同样适用于验证器的联合类型:
type Event =
| { kind: "click"; x: number; y: number }
| { kind: "keydown"; code: string };
const eventSchema = union([
object({ kind: literal("click"), x: number(), y: number() }),
object({ kind: literal("keydown"), code: string() }),
]);
schema 覆盖不到的分支,Infer 出来的类型也不会包含它,于是任何依赖该联合类型的 switch 都会因为缺分支而报错。这是「类型驱动」在运行时校验上的具体收益:漏掉一种输入形态,编译器会告诉你。
常见坑与报错对照
| 现象 | 原因 | 处理 |
|---|---|---|
as T 后运行时崩 | 断言不校验 | 换成 parse 或守卫 |
| 守卫通过但字段类型不对 | 只判存在未判类型 | 逐字段 typeof |
| 嵌套数组元素未校验 | 只判 Array.isArray | 递归校验元素 |
Infer 得到 unknown | 泛型参数没钉在实例类型上 | 加幽灵字段 _output |
unknown 上的属性访问报错 | 未收窄就访问 | 先 typeof/守卫 |
| 校验慢 | 每次请求重建 schema | schema 提到模块顶层 |
| 新增枚举值后无报错 | 缺穷尽性检查 | 加 assertNever |
最后补一句关于 strictNullChecks:本节所有守卫的写法都依赖它开启。若未开启,null 与 undefined 会被隐式并入所有类型,value !== null 这类判定会被编译器认为是多余代码,守卫的收窄也随之失效。这是 11.2 渐进式迁移与严格化路径
会专门展开的话题。
小结
这一节我们把「运行时校验」从一行 as 拆解到了一套可组合的机制:
as是声明不是检查:它把错误从编译期推到了运行时,只在受信任的边界内使用。value is T也是声明:编译器信任你的函数体,集中下注优于分散下注,但仍需自己保证正确。- 三个陷阱:只判存在不判类型、只判第一层不判嵌套、形状合法但语义非法。
- schema 即事实来源:把校验从代码变成数据,让类型、校验、文档、表单从同一处派生。
- 幽灵字段
_output:Infer能工作的前提是把泛型参数钉在实例类型上。 parse/safeParse/is:三者必须走同一份check,分工是「边界抛、内部返、判断用守卫」。- 短路与穷尽性:性能上选择是否 fail-fast,正确性上用
never把漏写变成编译错误。
到这里我们解决的是「数据进来时对不对」。但数据不只在入口出现,它还会被写出去再读回来——序列化成 JSON 存进数据库、通过消息队列发给下游、写入 localStorage 再取出。这一路上类型会丢失、Date 会变成字符串、undefined 会凭空消失。下一节我们就来看序列化与反序列化这条边界上,类型系统能做些什么。
阅读导航:上一节:9.3 循环依赖与类型-only 导入 · 下一节:10.2 序列化与反序列化类型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。