DEV Community

WDSEGA
WDSEGA

Posted on

TypeScript + Zod:构建类型安全的API,从验证到生成的完整方案

TypeScript的类型安全陷阱

TypeScript让JavaScript开发体验有了质的飞跃,但很多开发者忽略了一个关键事实:TypeScript的类型检查只在编译时生效,运行时完全不存在。

TypeScript + Zod 类型安全方案

这意味着,当数据从外部来源(API请求、数据库查询、用户输入)进入你的应用时,TypeScript无法保证这些数据的实际结构与类型定义一致。一个看似类型安全的接口,在运行时可能因为收到格式错误的数据而崩溃。

// 这个接口在编译时看起来很安全
interface User {
  id: string;
  name: string;
  email: string;
  age: number;
}

// 但运行时,外部数据可能是这样的:
const response = await fetch("/api/users/1");
const user: User = await response.json(); // 危险!没有任何运行时验证

// user.age 可能是字符串 "25",可能是 null,甚至不存在
// TypeScript 不会告诉你这些,直到代码在运行时崩溃
Enter fullscreen mode Exit fullscreen mode

这就是Zod存在的意义——它在运行时提供与TypeScript类型系统一致的验证能力,并且能从schema自动推导出TypeScript类型,实现"一次定义,处处安全"。

Zod基础:Schema定义

安装与快速上手

npm install zod
Enter fullscreen mode Exit fullscreen mode

Zod的核心概念是Schema。Schema既是运行时的验证器,也是TypeScript类型的来源:

import { z } from "zod";

// 定义一个用户Schema
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2, "姓名至少2个字符").max(50, "姓名最多50个字符"),
  email: z.string().email("请输入有效的邮箱地址"),
  age: z.number().int().min(0).max(150),
  role: z.enum(["admin", "editor", "viewer"]),
  isActive: z.boolean().default(true),
  createdAt: z.string().datetime(),
});

// 自动推导TypeScript类型 —— 与手动定义的interface完全等价
type User = z.infer<typeof UserSchema>;
// 等价于:
// type User = {
//   id: string;
//   name: string;
//   email: string;
//   age: number;
//   role: "admin" | "editor" | "viewer";
//   isActive: boolean;
//   createdAt: string;
// }
Enter fullscreen mode Exit fullscreen mode

常用验证器一览

// 字符串验证
z.string()                    // 基础字符串
z.string().min(5)             // 最小长度
z.string().max(100)           // 最大长度
z.string().email()            // 邮箱格式
z.string().url()              // URL格式
z.string().uuid()             // UUID格式
z.string().regex(/^[a-z]+$/)  // 正则匹配
z.string().trim()             // 自动去除首尾空格
z.string().nonempty()         // 非空字符串

// 数字验证
z.number()                    // 基础数字
z.number().int()              // 整数
z.number().positive()         // 正数
z.number().min(0).max(100)    // 范围约束
z.number().multipleOf(0.01)   // 步长约束(适合金额)

// 其他类型
z.boolean()                   // 布尔值
z.date()                      // 日期对象
z.bigint()                    // BigInt
z.null()                      // 仅null
z.undefined()                 // 仅undefined
z.nullable(z.string())        // string | null
z.optional(z.string())        // string | undefined

// 高级类型
z.array(z.string())           // 字符串数组
z.tuple([z.string(), z.number()])  // 元组
z.record(z.string(), z.number())  // Record<string, number>
z.map(z.string(), z.number())     // Map<string, number>
z.set(z.string())             // Set<string>

// 联合类型与判别联合
z.union([z.string(), z.number()])    // string | number
z.literal("admin")                   // 字面量类型
z.discriminatedUnion("type", [       // 判别联合
  z.object({ type: z.literal("text"), content: z.string() }),
  z.object({ type: z.literal("image"), url: z.string().url() }),
]);
Enter fullscreen mode Exit fullscreen mode

验证API请求与响应

Express集成

将Zod集成到Express中间件中,实现请求参数的自动验证:

// src/middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { ZodSchema, ZodError } from "zod";

interface ValidationSchemas {
  body?: ZodSchema;
  query?: ZodSchema;
  params?: ZodSchema;
}

export function validate(schemas: ValidationSchemas) {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      if (schemas.body) {
        req.body = schemas.body.parse(req.body);
      }
      if (schemas.query) {
        req.query = schemas.query.parse(req.query) as any;
      }
      if (schemas.params) {
        req.params = schemas.params.parse(req.params) as any;
      }
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        res.status(400).json({
          success: false,
          error: "请求参数验证失败",
          details: error.errors.map((err) => ({
            field: err.path.join("."),
            message: err.message,
            code: err.code,
          })),
        });
        return;
      }
      next(error);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

实际API示例

// src/schemas/user.schema.ts
import { z } from "zod";

export const CreateUserSchema = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  password: z
    .string()
    .min(8, "密码至少8位")
    .regex(/[A-Z]/, "密码必须包含大写字母")
    .regex(/[0-9]/, "密码必须包含数字")
    .regex(/[^A-Za-z0-9]/, "密码必须包含特殊字符"),
  role: z.enum(["admin", "editor", "viewer"]).default("viewer"),
});

export const UpdateUserSchema = CreateUserSchema.partial().omit({
  password: true,
});

export const QueryUsersSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  pageSize: z.coerce.number().int().min(1).max(100).default(20),
  search: z.string().optional(),
  role: z.enum(["admin", "editor", "viewer"]).optional(),
  sortBy: z.enum(["name", "email", "createdAt"]).default("createdAt"),
  sortOrder: z.enum(["asc", "desc"]).default("desc"),
});

export const UserIdParamsSchema = z.object({
  id: z.string().uuid(),
});

// 推导类型

## 高级特性Refinements与Transforms

### 自定义验证规则Refinement

Enter fullscreen mode Exit fullscreen mode


typescript
// 密码确认匹配
const PasswordFormSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "两次输入的密码不一致",
path: ["confirmPassword"], // 错误指向confirmPassword字段
});

// 日期范围验证
const EventSchema = z
.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
})
.refine((data) => new Date(data.startDate) < new Date(data.endDate), {
message: "结束日期必须晚于开始日期",
path: ["endDate"],
});

// 多条件refinement
const RegistrationSchema = z
.object({
email: z.string().email(),
age: z.number().int().min(13),
parentConsent: z.boolean().optional(),
})
.refine(
(data) => {
// 未满18岁需要家长同意
if (data.age < 18) return data.parentConsent === true;
return true;
},
{
message: "未满18岁需要家长同意书",
path: ["parentConsent"],
}
);

// 使用superRefine进行更复杂的多字段交叉验证
const OrderSchema = z
.object({
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().int().min(1),
price: z.number().positive(),
})
),
couponCode: z.string().optional(),
totalAmount: z.number().positive(),
})
.superRefine((data, ctx) => {
const subtotal = data.items.reduce(
(sum, item) => sum + item.quantity * item.price,
0
);

// 验证总金额是否正确
if (Math.abs(data.totalAmount - subtotal) > 0.01) {
  ctx.addIssue({
    code: z.ZodIssueCode.custom,
    message: `总金额计算错误,应为 ${subtotal}`,
    path: ["totalAmount"],
Enter fullscreen mode Exit fullscreen mode

从Zod Schema生成TypeScript类型

Zod最大的优势之一是类型与验证的统一。通过 z.infer,你可以从schema自动生成TypeScript类型:

// schemas/api.schema.ts
import { z } from "zod";

// ===== 请求Schema =====
export const CreateArticleSchema = z.object({
  title: z.string().min(5).max(200),
  content: z.string().min(100),
  tags: z.array(z.string()).max(10),
  category: z.enum(["tech", "design", "business"]),
  isDraft: z.boolean().default(false),
});

export const PaginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(50).default(20),
});

// ===== 响应Schema =====
export const ArticleSchema = z.object({
  id: z.string().uuid(),
  title: z.string(),
  content: z.string(),
  tags: z.array(z.string()),
  category: z.string(),
  isDraft: z.boolean(),
  authorId: z.string().uuid(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

export const PaginatedResponseSchema = z.object({
  data: z.array(ArticleSchema),
  pagination: z.object({
    page: z.number(),
    limit: z.number(),
    total: z.number(),

## 错误处理模式

### 统一错误格式化

Enter fullscreen mode Exit fullscreen mode


typescript
// src/utils/zod-error.ts
import { ZodError } from "zod";

interface FormattedError {
field: string;
message: string;
code: string;
}

export function formatZodError(error: ZodError): FormattedError[] {
return error.errors.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
code: issue.code,
}));
}

export function createValidationError(error: ZodError) {
return {
success: false,
error: "VALIDATION_ERROR",
message: "请求数据验证失败",
details: formatZodError(error),
};
}

// 自定义错误映射
const ERROR_MESSAGES: Record = {
ZodString_email: "请输入有效的邮箱地址",
ZodString_url: "请输入有效的URL",
ZodString_uuid: "请输入有效的UUID",
ZodString_min: "内容过短",
ZodString_max: "内容过长",
ZodNumber_min: "数值过小",
ZodNumber_max: "数值过大",
ZodEnum_invalidEnumValue: "请选择有效的选项",
};

export function getFriendlyMessage(error: ZodError): string {
const firstError = error.errors[0];
const key = ${firstError.code};
return ERROR_MESSAGES[key] || firstError.message || "数据验证失败";
}


### 前端表单验证集成

Enter fullscreen mode Exit fullscreen mode


typescript
// src/hooks/useFormValidation.ts
import { useState, useCallback } from "react";
import { ZodSchema, ZodError } from "zod";

interface UseFormValidationOptions {
schema: ZodSchema;
onSubmit: (values: T) => Promise;
initialValues?: Partial;
}

interface FormState {
values: Partial;
errors: Record;
isSubmitting: boolean;
isValid: boolean;
}

export function useFormValidation({
schema,
onSubmit,
initialValues = {},
}: UseFormValidationOptions) {
const [state, setState] = useState>({
values: initialValues,
errors: {},
isSubmitting: false,
isValid: false,
});

const validateField = useCallback(
(name: string, value: unknown) => {
try {
// 验证单个字段
const fieldSchema = (schema as any).shape[name];
if (fieldSchema) {
fieldSchema.parse(value);
setState((prev) => {
const newErrors = { ...prev.errors };

测试策略

为Zod Schema编写单元测试,确保验证逻辑的正确性:

// src/schemas/__tests__/user.schema.test.ts
import { describe, it, expect } from "vitest";
import { CreateUserSchema, UpdateUserSchema } from "../user.schema";

describe("CreateUserSchema", () => {
  it("应该通过有效的用户数据", () => {
    const validData = {
      name: "张三",
      email: "zhangsan@example.com",
      password: "SecurePass123!",
      role: "viewer" as const,
    };

    const result = CreateUserSchema.safeParse(validData);
    expect(result.success).toBe(true);
  });

  it("应该拒绝过短的姓名", () => {
    const result = CreateUserSchema.safeParse({
      name: "A",
      email: "test@example.com",
      password: "SecurePass123!",
    });

    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.errors[0].path).toContain("name");
    }
  });

  it("应该拒绝无效的邮箱格式", () => {
    const result = CreateUserSchema.safeParse({
      name: "张三",
      email: "not-an-email",
      password: "SecurePass123!",
    });

    expect(result.success).toBe(false);
  });

  it("应该拒绝不满足复杂度要求的密码", () => {
    const weakPasswords = ["short", "nodigits!", "NOLOWERCASE1!", "noupper1!"];

    for (const password of weakPasswords) {
      const result = CreateUserSchema.safeParse({
        name: "张三",
        email: "test@example.com",
        password,

## 实战中的最佳实践

### 1. Schema组织结构

Enter fullscreen mode Exit fullscreen mode

src/
├── schemas/
│ ├── index.ts # 统一导出
│ ├── common/ # 通用Schema
│ │ ├── pagination.ts
│ │ └── id.ts
│ ├── user/
│ │ ├── user.schema.ts
│ │ └── user.types.ts # 从schema推导的类型
│ └── article/
│ ├── article.schema.ts
│ └── article.types.ts


### 2. Schema复用与组合

Enter fullscreen mode Exit fullscreen mode


typescript
// schemas/common.ts
export const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});

export const TimestampSchema = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),

总结

TypeScript + Zod的组合为API开发提供了端到端的类型安全保障。核心要点:

  1. TypeScript类型只在编译时生效,运行时需要Zod提供实际验证
  2. Schema是唯一数据源,通过 z.infer 自动推导类型,避免重复定义

📢 本文为精简版,完整版包含独家工具推荐和深度分析,请访问 WD Tech Blog 查看!

关注我的博客获取最新科技资讯、AI教程和效率工具推荐!

Top comments (0)