第13章 运行时类型检查
本章定位:学习TypeScript编译时类型检查之外的运行时验证,掌握zod、yup、joi等流行验证库的使用,实现从类型定义到验证器的自动转换。
13.1 编译时检查 vs 运行时检查
Section titled “13.1 编译时检查 vs 运行时检查”TypeScript的限制
Section titled “TypeScript的限制”TypeScript的类型检查发生在编译时,运行时没有类型信息:
// 编译时检查function process(value: number): number { return value * 2;}
process("hello"); // 编译错误!但JavaScript运行时不检查编译为JavaScript后:
function process(value) { return value * 2;}
process("hello"); // 返回 "hellohello"!为什么需要运行时验证
Section titled “为什么需要运行时验证”- 外部数据:API返回、用户输入、文件读取
- 动态数据:从数据库、第三方服务获取
- 序列化/反序列化:JSON.parse、localStorage
- 安全:防止恶意输入
// 场景:从API获取数据const response = await fetch("https://api.example.com/user");const data = await response.json();
// data的类型是any,TypeScript不知道具体结构// 需要运行时验证13.2 JSON Schema与 ajv
Section titled “13.2 JSON Schema与 ajv”JSON Schema基础
Section titled “JSON Schema基础”JSON Schema是一种描述JSON数据结构的标准:
const schema = { type: "object", properties: { name: { type: "string" }, age: { type: "number" }, email: { type: "string", format: "email" } }, required: ["name", "email"]};ajv是JavaScript最流行的JSON Schema验证器:
npm install ajvnpm install @types/json-schema # 类型定义import Ajv from "ajv";
const ajv = new Ajv();
const schema = { type: "object", properties: { name: { type: "string" }, age: { type: "number", minimum: 0 }, email: { type: "string", format: "email" } }, required: ["name", "email"]};
const validate = ajv.compile(schema);
const validData = { name: "Alice", age: 30, email: "alice@example.com"};
const invalidData = { name: "Bob", age: -5, email: "not-an-email"};
console.log(validate(validData)); // trueconsole.log(validate(invalidData)); // falseconsole.log(validate.errors); // 错误详情ajv的完整示例
Section titled “ajv的完整示例”import Ajv, { ErrorObject } from "ajv";
const ajv = new Ajv({ allErrors: true });
const userSchema = { type: "object", properties: { id: { type: "string" }, name: { type: "string", minLength: 1, maxLength: 100 }, age: { type: "number", minimum: 0, maximum: 150 }, email: { type: "string", format: "email" }, role: { type: "string", enum: ["admin", "user", "guest"] }, tags: { type: "array", items: { type: "string" }, minItems: 0, maxItems: 10 } }, required: ["id", "name", "email"]};
const validate = ajv.compile(userSchema);
function validateUser(data: unknown): { valid: boolean; errors?: ErrorObject[] } { const valid = validate(data); return { valid, errors: valid ? undefined : validate.errors || undefined };}
const result = validateUser({ id: "123", name: "", email: "invalid"});
if (!result.valid) { result.errors?.forEach(err => { console.error(`${err.instancePath}: ${err.message}`); }); // /name: should NOT have fewer than 1 characters // /email: should match format "email"}Python对比:
from jsonschema import validate, ValidationError
schema = { "type": "object", "properties": { "name": {"type": "string", "minLength": 1}, "age": {"type": "number", "minimum": 0} }, "required": ["name"]}
data = {"name": "", "age": -5}
try: validate(instance=data, schema=schema)except ValidationError as e: print(f"{e.instance}: {e.message}")13.3 zod、yup、joi对比
Section titled “13.3 zod、yup、joi对比”现代TypeScript优先的验证库,类型推断强大:
npm install zodimport { z } from "zod";
// 定义schemaconst UserSchema = z.object({ name: z.string().min(1).max(100), age: z.number().int().min(0).max(150).optional(), email: z.string().email(), role: z.enum(["admin", "user", "guest"]), createdAt: z.string().datetime().optional()});
// 推断TypeScript类型type User = z.infer<typeof UserSchema>;
// 验证const result = UserSchema.safeParse({ name: "Alice", age: 30, email: "alice@example.com", role: "user"});
if (result.success) { console.log(result.data); // 类型安全的User对象} else { console.error(result.error.issues);}zod的高级特性
Section titled “zod的高级特性”import { z } from "zod";
// 嵌套对象const AddressSchema = z.object({ street: z.string(), city: z.string(), zip: z.string().regex(/^\d{5}$/)});
const CompanySchema = z.object({ name: z.string(), address: AddressSchema});
// 变体(联合)const ShapeSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("circle"), radius: z.number().positive() }), z.object({ kind: z.literal("rectangle"), width: z.number().positive(), height: z.number().positive() })]);
// 数组const StringArraySchema = z.array(z.string()).min(1).max(100);
// Mapconst StringNumberMapSchema = z.map(z.string(), z.number());
// 转换const IntegerSchema = z.string().transform(val => parseInt(val, 10));
// 预处理const PreprocessedSchema = z.preprocess( (val) => typeof val === "string" ? JSON.parse(val) : val, z.object({ name: z.string() }));
// 可选和默认值const WithDefaultSchema = z.object({ name: z.string().default("Anonymous"), age: z.number().default(0)});成熟的验证库,API类似ActiveRecord:
npm install yupimport * as yup from "yup";
const userSchema = yup.object({ name: yup.string() .required("Name is required") .min(1, "Name must be at least 1 character") .max(100, "Name must be at most 100 characters"), age: yup.number() .integer("Age must be an integer") .min(0, "Age must be non-negative") .max(150, "Age must be at most 150") .optional(), email: yup.string() .required("Email is required") .email("Invalid email format"), role: yup.string() .oneOf(["admin", "user", "guest"], "Invalid role") .required()});
// 类型推断type User = yup.InferType<typeof userSchema>;
// 验证async function validateUser(data: unknown): Promise<User> { return userSchema.validate(data, { abortEarly: false });}
try { const user = await validateUser({ name: "Alice", email: "alice@example.com", role: "user" }); console.log(user);} catch (error) { console.error(error.errors);}Hapi框架的验证库,表达力强:
npm install joiimport Joi from "joi";
const userSchema = Joi.object({ name: Joi.string() .min(1) .max(100) .required(), age: Joi.number() .integer() .min(0) .max(150) .optional(), email: Joi.string() .email() .required(), role: Joi.string() .valid("admin", "user", "guest")});
// 验证const { error, value } = userSchema.validate({ name: "Alice", age: 30, email: "alice@example.com", role: "user"});
if (error) { console.error(error.details.map(d => d.message));} else { console.log(value);}
// 转换为TypeScript类型(需要joi-to-typescript或手动)| 特性 | zod | yup | joi |
|---|---|---|---|
| TypeScript优先 | ✓ | 一般 | 一般 |
| 类型推断 | 强大 | 中等 | 较弱 |
| Bundle大小 | 小 | 中 | 大 |
| Schema-as-type | 原生 | 需额外定义 | 需转换 |
| 不可变性 | 原生支持 | 需配置 | 需配置 |
// TypeScript优先,推荐zodimport { z } from "zod";const schema = z.object({ name: z.string() });
// 对象验证为主,推荐yupimport * as yup from "yup";const schema = yup.object({ name: yup.string().required() });
// 复杂验证场景,推荐joiimport Joi from "joi";const schema = Joi.object({ name: Joi.string().required() });13.4 从类型到验证器
Section titled “13.4 从类型到验证器”TypeScript类型到zod schema
Section titled “TypeScript类型到zod schema”手动映射:
import { z } from "zod";
// TypeScript类型interface User { name: string; age: number; email: string;}
// 对应zod schemaconst UserSchema = z.object({ name: z.string(), age: z.number(), email: z.string().email()});自动生成zod schema
Section titled “自动生成zod schema”import { z } from "zod";
// 从类型推断schematype User = { name: string; age: number; email: string };const UserSchema: z.ZodType<User> = z.object({ name: z.string(), age: z.number(), email: z.string().email()});ts-to-zod库
Section titled “ts-to-zod库”使用ts-to-zod从TypeScript生成zod schema:
npm install ts-to-zodexport interface User { id: string; name: string; email: string; age?: number;}import { z } from "zod";import { User } from "./user.types";
// 手动创建export const userSchema = z.object({ id: z.string().uuid(), name: z.string().min(1).max(100), email: z.string().email(), age: z.number().int().positive().optional()});JSON Schema到zod
Section titled “JSON Schema到zod”import { z } from "zod";
const jsonSchema = { type: "object", properties: { name: { type: "string" }, age: { type: "number" } }, required: ["name"]};
function jsonSchemaToZod(schema: any): z.ZodType { if (schema.type === "object") { const shape: Record<string, z.ZodType> = {}; for (const [key, value] of Object.entries(schema.properties)) { shape[key] = jsonSchemaToZod(value); } return z.object(shape); }
if (schema.type === "string") { if (schema.format === "email") { return z.string().email(); } return z.string(); }
if (schema.type === "number") { return z.number(); }
return z.any();}
const zodSchema = jsonSchemaToZod(jsonSchema);13.5 渐进式类型验证
Section titled “13.5 渐进式类型验证”import { z } from "zod";
// 完整schemaconst FullUserSchema = z.object({ name: z.string(), age: z.number(), email: z.string().email(), role: z.enum(["admin", "user", "guest"])});
// 部分schema(用于更新)const PartialUserSchema = FullUserSchema.partial();
// 或者指定可选字段const UpdateUserSchema = z.object({ name: z.string().optional(), age: z.number().optional()});import { z } from "zod";
// 带转换的schemaconst StringToNumberSchema = z.string().transform(val => { const num = parseInt(val, 10); if (isNaN(num)) { throw new Error("Invalid number"); } return num;});
// 先验证后转换const result = StringToNumberSchema.safeParse("42");if (result.success) { console.log(typeof result.data); // number}验证API响应
Section titled “验证API响应”import { z } from "zod";
const UserSchema = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email(), createdAt: z.string().datetime()});
const UserListResponseSchema = z.object({ users: z.array(UserSchema), total: z.number(), page: z.number()});
async function fetchUsers(page: number) { const response = await fetch(`/api/users?page=${page}`); const data = await response.json();
const result = UserListResponseSchema.safeParse(data); if (!result.success) { throw new Error(`Invalid response: ${result.error.message}`); }
return result.data;}13.6 错误消息国际化
Section titled “13.6 错误消息国际化”zod国际化
Section titled “zod国际化”import { z } from "zod";
// 自定义错误消息const UserSchema = z.object({ name: z.string().min(2, "Name must be at least 2 characters"), email: z.string().email("Please enter a valid email address"), age: z.number().min(0, "Age cannot be negative")});
// 收集所有错误function formatErrors(error: z.ZodError) { return error.errors.map(err => { const path = err.path.join("."); return `${path}: ${err.message}`; }).join("\n");}yup国际化
Section titled “yup国际化”import * as yup from "yup";
const userSchema = yup.object({ name: yup.string() .required("${path} is required") .min(2, "${path} must be at least ${min} characters"), email: yup.string() .required("${path} is required") .email("${path} must be a valid email")});
// 设置默认消息yup.setLocale({ mixed: { required: "${path} is required", default: "${path} is invalid" }, string: { email: "${path} must be a valid email", min: "${path} must be at least ${min} characters" }, number: { min: "${path} must be at least ${min}" }});自定义验证错误格式
Section titled “自定义验证错误格式”import { z } from "zod";
const ErrorSchema = z.object({ field: z.string(), message: z.string(), code: z.string()});
type ValidationError = z.infer<typeof ErrorSchema>;
function formatZodErrors(error: z.ZodError): ValidationError[] { return error.errors.map(err => ({ field: err.path.join("."), message: err.message, code: err.code }));}
// 使用const result = UserSchema.safeParse(invalidData);if (!result.success) { const errors = formatZodErrors(result.error); console.log(errors); // [{ field: "name", message: "Name must be at least 2 characters", code: "too_small" }]}13.7 实际应用场景
Section titled “13.7 实际应用场景”import { z } from "zod";
const ContactFormSchema = z.object({ name: z.string().min(1, "Name is required"), email: z.string().email("Invalid email"), subject: z.string().min(5, "Subject must be at least 5 characters"), message: z.string().min(20, "Message must be at least 20 characters")});
type ContactForm = z.infer<typeof ContactFormSchema>;
class ContactFormValidator { validate(data: unknown): { valid: boolean; data?: ContactForm; errors?: ValidationError[] } { const result = ContactFormSchema.safeParse(data);
if (result.success) { return { valid: true, data: result.data }; }
return { valid: false, errors: result.error.errors.map(err => ({ field: err.path.join("."), message: err.message, code: err.code })) }; }}
const validator = new ContactFormValidator();const result = validator.validate({ name: "Alice", email: "invalid-email", subject: "Hi", message: "Hello"});
if (!result.valid) { result.errors?.forEach(err => { console.log(`${err.field}: ${err.message}`); });}API请求验证
Section titled “API请求验证”import { z } from "zod";
const CreateOrderSchema = z.object({ productId: z.string().uuid(), quantity: z.number().int().positive().max(100), shippingAddress: z.object({ street: z.string().min(1), city: z.string().min(1), zip: z.string().regex(/^\d{5}$/), country: z.string().length(2) }), couponCode: z.string().optional()});
type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;
async function createOrder(request: unknown): Promise<CreateOrderRequest> { const result = CreateOrderSchema.safeParse(request);
if (!result.success) { throw { status: 400, message: "Invalid request", errors: result.error.errors }; }
return result.data;}
// 使用try { const order = await createOrder(req.body); // order是类型安全的CreateOrderRequest} catch (error) { res.status(400).json(error);}Python对比:
from pydantic import BaseModel, Field, ValidationError
class Address(BaseModel): street: str city: str zip: str = Field(regex=r"^\d{5}$")
class CreateOrderRequest(BaseModel): product_id: str quantity: int = Field(gt=0, le=100) shipping_address: Address coupon_code: str | None = None
try: order = CreateOrderRequest(**request)except ValidationError as e: raise {"status": 400, "message": "Invalid request", "errors": e.errors()}13.8 本章小结
Section titled “13.8 本章小结”本章学习了TypeScript的运行时类型检查:
-
编译时vs运行时:
- TypeScript编译时检查类型
- 运行时需要额外验证
-
JSON Schema与ajv:
- 标准JSON数据描述语言
- ajv是流行的验证器
-
zod、yup、joi对比:
- zod是TypeScript优先
- yup适合对象验证
- joi功能丰富
-
从类型到验证器:
- 类型定义可以映射到schema
- zod支持从schema推断类型
-
渐进式验证:
- 使用partial()创建可选schema
- 支持数据转换
-
错误消息国际化:
- 自定义错误消息
- 格式化错误输出
练习13.1:zod基础
Section titled “练习13.1:zod基础”使用zod定义用户注册表单的验证schema。
练习13.2:嵌套验证
Section titled “练习13.2:嵌套验证”创建包含嵌套对象的验证schema(如公司-部门-员工)。
练习13.3:变体验证
Section titled “练习13.3:变体验证”使用discriminatedUnion验证不同类型的消息(成功/错误/警告)。
练习13.4:自定义错误
Section titled “练习13.4:自定义错误”为验证器添加自定义错误消息和错误代码。
练习13.5:API验证
Section titled “练习13.5:API验证”实现一个Express中间件,验证请求body。
附录部分提供了Python与TypeScript的对照速查表、tsconfig.json详解和进阶学习资源。