Skip to content

第13章 运行时类型检查

本章定位:学习TypeScript编译时类型检查之外的运行时验证,掌握zod、yup、joi等流行验证库的使用,实现从类型定义到验证器的自动转换。


TypeScript的类型检查发生在编译时,运行时没有类型信息:

// 编译时检查
function process(value: number): number {
return value * 2;
}
process("hello"); // 编译错误!但JavaScript运行时不检查

编译为JavaScript后:

function process(value) {
return value * 2;
}
process("hello"); // 返回 "hellohello"!
  1. 外部数据:API返回、用户输入、文件读取
  2. 动态数据:从数据库、第三方服务获取
  3. 序列化/反序列化:JSON.parse、localStorage
  4. 安全:防止恶意输入
// 场景:从API获取数据
const response = await fetch("https://api.example.com/user");
const data = await response.json();
// data的类型是any,TypeScript不知道具体结构
// 需要运行时验证

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验证器:

Terminal window
npm install ajv
npm 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)); // true
console.log(validate(invalidData)); // false
console.log(validate.errors); // 错误详情
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}")

现代TypeScript优先的验证库,类型推断强大:

Terminal window
npm install zod
import { z } from "zod";
// 定义schema
const 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);
}
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);
// Map
const 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:

Terminal window
npm install yup
import * 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框架的验证库,表达力强:

Terminal window
npm install joi
import 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或手动)
特性zodyupjoi
TypeScript优先✓一般一般
类型推断强大中等较弱
Bundle大小小中大
Schema-as-type原生需额外定义需转换
不可变性原生支持需配置需配置
// TypeScript优先,推荐zod
import { z } from "zod";
const schema = z.object({ name: z.string() });
// 对象验证为主,推荐yup
import * as yup from "yup";
const schema = yup.object({ name: yup.string().required() });
// 复杂验证场景,推荐joi
import Joi from "joi";
const schema = Joi.object({ name: Joi.string().required() });

手动映射:

import { z } from "zod";
// TypeScript类型
interface User {
name: string;
age: number;
email: string;
}
// 对应zod schema
const UserSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email()
});
import { z } from "zod";
// 从类型推断schema
type 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从TypeScript生成zod schema:

Terminal window
npm install ts-to-zod
user.types.ts
export interface User {
id: string;
name: string;
email: string;
age?: number;
}
user.schema.ts
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()
});
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);

import { z } from "zod";
// 完整schema
const 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";
// 带转换的schema
const 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
}
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;
}

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");
}
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}"
}
});
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" }]
}

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}`);
});
}
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()}

本章学习了TypeScript的运行时类型检查:

  1. 编译时vs运行时:

    • TypeScript编译时检查类型
    • 运行时需要额外验证
  2. JSON Schema与ajv:

    • 标准JSON数据描述语言
    • ajv是流行的验证器
  3. zod、yup、joi对比:

    • zod是TypeScript优先
    • yup适合对象验证
    • joi功能丰富
  4. 从类型到验证器:

    • 类型定义可以映射到schema
    • zod支持从schema推断类型
  5. 渐进式验证:

    • 使用partial()创建可选schema
    • 支持数据转换
  6. 错误消息国际化:

    • 自定义错误消息
    • 格式化错误输出

使用zod定义用户注册表单的验证schema。

创建包含嵌套对象的验证schema(如公司-部门-员工)。

使用discriminatedUnion验证不同类型的消息(成功/错误/警告)。

为验证器添加自定义错误消息和错误代码。

实现一个Express中间件,验证请求body。



附录部分提供了Python与TypeScript的对照速查表、tsconfig.json详解和进阶学习资源。