运行时校验(Zod / Valibot)

掌握 Zod/Valibot 等 schema 验证库的使用,理解编译时类型与运行时边界的桥接方案。

运行时校验(Zod / Valibot)

类型系统的边界

TypeScript 的类型检查在编译后完全消失——运行时没有类型信息。这意味着来自外部的数据(API 响应、用户输入、URL 参数、环境变量、localStorage)在运行时可能不符合 TypeScript 声明的类型。Schema 验证库在运行时边界建立校验层,并自动推导 TypeScript 类型,实现编译时类型与运行时安全的统一。

面试定位:运行时校验体现候选人对类型安全的完整理解——不仅关注编译时,更关注类型断言在运行时可能失效的场景。面试中常问"API 数据怎么保证类型安全"这类问题。


为什么需要运行时校验

类型断言的危险

// 常见但危险的模式
interface User {
  id: string;
  name: string;
  email: string;
}

const response = await fetch("/api/user/1");
const user = (await response.json()) as User; // 纯编译时断言

// 如果 API 返回 { id: 1, name: "Alice" }(id 是 number,没有 email)
// TypeScript 不会报错,但后续使用 user.email.toLowerCase() 会崩溃

需要运行时校验的边界

边界 示例
网络请求 API 响应、WebSocket 消息
用户输入 表单数据、URL 参数、搜索查询
持久化存储 localStorage、IndexedDB、cookie
环境变量 process.env.env 文件
文件系统 JSON 配置文件、YAML
第三方集成 Webhook payload、OAuth 回调参数

Zod

Zod 是目前最流行的 TypeScript-first schema 验证库。

基础 Schema

import { z } from "zod";

// 定义 schema
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(["admin", "editor", "viewer"]),
  createdAt: z.string().datetime(),
});

// 从 schema 推导 TypeScript 类型
type User = z.infer<typeof UserSchema>;
// { id: string; name: string; email: string; age?: number; role: "admin" | "editor" | "viewer"; createdAt: string }

// 运行时校验
const result = UserSchema.safeParse(data);
if (result.success) {
  const user: User = result.data; // 类型安全
} else {
  console.error(result.error.issues);
}

组合与复用

// 基础 schema
const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  country: z.string(),
  postalCode: z.string(),
});

// 继承和扩展
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
const UpdateUserSchema = CreateUserSchema.partial();
const UserWithAddressSchema = UserSchema.extend({
  address: AddressSchema.optional(),
});

// 联合类型
const ResultSchema = z.discriminatedUnion("status", [
  z.object({ status: z.literal("success"), data: UserSchema }),
  z.object({ status: z.literal("error"), message: z.string() }),
]);

转换(Transform)

const DateSchema = z.string().datetime().transform((str) => new Date(str));
// 输入:string,输出:Date

const PaginationSchema = z.object({
  page: z.string().transform(Number).pipe(z.number().int().min(1)),
  limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100)),
});
// URL 查询参数:string → number,并验证范围

与 API 集成

// 类型安全的 fetch 封装
async function fetchWithSchema<T>(
  url: string,
  schema: z.ZodSchema<T>
): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  const data = await response.json();
  return schema.parse(data); // 校验 + 类型推断
}

const user = await fetchWithSchema("/api/user/1", UserSchema);
// user 的类型是 User,且经过运行时校验

环境变量校验

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  PORT: z.string().transform(Number).pipe(z.number().int().min(1).max(65535)).default("3000"),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});

export const env = EnvSchema.parse(process.env);
// 应用启动时立即校验,类型完全确定

Valibot

Valibot 是 Zod 的轻量替代方案,采用函数组合式 API 和 tree-shakable 架构。

对比 Zod

特性 Zod Valibot
Bundle 大小 ~13KB (min+gzip) ~1-5KB (按需引入)
API 风格 方法链式 函数管道
Tree-shaking 有限(类原型方法) 完全 tree-shakable
生态成熟度 非常成熟 快速成长
性能 良好 更优

Valibot 语法

import * as v from "valibot";

const UserSchema = v.object({
  id: v.pipe(v.string(), v.uuid()),
  name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
  email: v.pipe(v.string(), v.email()),
  age: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
  role: v.picklist(["admin", "editor", "viewer"]),
});

type User = v.InferOutput<typeof UserSchema>;

// 校验
const result = v.safeParse(UserSchema, data);
if (result.success) {
  const user = result.output;
}

与框架集成

React Hook Form + Zod

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const FormSchema = z.object({
  email: z.string().email("请输入有效的邮箱地址"),
  password: z.string().min(8, "密码至少 8 个字符"),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "两次密码输入不一致",
  path: ["confirmPassword"],
});

type FormData = z.infer<typeof FormSchema>;

function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(FormSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}
      {/* ... */}
    </form>
  );
}

Next.js Server Actions + Zod

"use server";

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  tags: z.array(z.string()).max(5),
});

export async function createPost(formData: FormData) {
  const result = CreatePostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
    tags: formData.getAll("tags"),
  });

  if (!result.success) {
    return { error: result.error.flatten() };
  }

  // result.data 类型完全确定
  await db.post.create({ data: result.data });
  return { success: true };
}

Schema 设计模式

共享 Schema 策略

// schemas/user.ts — 作为单一事实来源
export const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
});

// 衍生 schema
export const CreateUserSchema = UserSchema.omit({ id: true });
export const UpdateUserSchema = CreateUserSchema.partial();
export const UserListSchema = z.array(UserSchema);

// 衍生类型
export type User = z.infer<typeof UserSchema>;
export type CreateUser = z.infer<typeof CreateUserSchema>;
export type UpdateUser = z.infer<typeof UpdateUserSchema>;

输入/输出类型分离

const DateTransformSchema = z.object({
  createdAt: z.string().datetime().transform((s) => new Date(s)),
});

type Input = z.input<typeof DateTransformSchema>;
// { createdAt: string }

type Output = z.output<typeof DateTransformSchema>;
// { createdAt: Date }

面试高频问题

Q: TypeScript 类型和运行时校验有什么关系?为什么需要两者?

回答要点:TypeScript 类型在编译后被擦除,不存在于运行时。对于应用内部的数据流,编译时类型检查足以保证安全。但在系统边界(API 响应、用户输入等),数据来源不受 TypeScript 控制,需要运行时校验确保数据符合预期形状。Schema 验证库的价值在于将这两层统一起来——从 schema 推导出 TypeScript 类型,实现"定义一次,编译时和运行时双重保障"。

Q: Zod 和 Valibot 如何选择?

回答要点:Zod 生态更成熟,与 React Hook Form、tRPC、Next.js 等框架的集成更完善,适合大多数项目。Valibot 的函数组合式 API 实现了完全的 tree-shaking,在对 bundle 大小敏感的场景(如组件库、轻量应用)中有优势。两者的核心理念一致——schema 作为类型的运行时表示。


延展阅读