运行时校验(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 作为类型的运行时表示。