《TypeScript高级编程》10.1 类型守卫与验证库原理

本节讲运行时校验的第一道防线:类型守卫与验证库。先说清 is 守卫的声明本质与它并不能真正校验的事实,再拆解手写守卫在嵌套对象、数组、穷尽性检查上的漏判与误判,然后从零实现一个以 schema 为唯一事实来源的验证器,讲透 parse 与 safeParse 的分工和 InferOutput 类型推导的原理。读完你能为项目选对守卫写法,并看懂主流验证库的类型推导机制。

本节目标:读完这一节,你能说清 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/守卫
校验慢每次请求重建 schemaschema 提到模块顶层
新增枚举值后无报错缺穷尽性检查加 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 序列化与反序列化类型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes