TypeScript运行时类型校验实战:从Zod到TypeBox的6种生产模式

前端工程

TypeScript的类型在运行时就是空气

你精心定义了interface User,编译零错误,线上却炸了——API返回了null代替string,第三方SDK的类型声明和实际行为完全不一致,JSON.parse()的返回值永远是anyTypeScript的类型在编译后就被完全擦除,运行时没有任何保障。2026年,运行时类型校验已经从"可选"变成了"必选"——Zod、TypeBox、io-ts、valibot四大方案各有所长,tRPC更是将端到端类型安全推向了新高度。

本文将从核心概念出发,带你完成Zod Schema定义→TypeBox JSON Schema生成→io-ts Either错误处理→valibot轻量校验→tRPC端到端类型安全→生产API校验中间件的6种生产模式,从选型到落地,一步不落。


核心概念

概念 说明
Runtime Type Checking 运行时对数据结构进行校验,弥补TypeScript类型擦除的缺陷
Schema Validation 声明式定义数据结构,同时提供校验逻辑和类型推导
Type Inference 从Schema自动推导TypeScript类型,消除interface与校验逻辑的重复维护
JSON Schema 标准化的数据描述格式,可被多种工具和语言消费
Branded Types 通过唯一标记区分同构类型,防止类型混用(如UserId vs OrderId)
Decoding 校验+转换的组合操作,将原始输入解码为类型安全的输出

校验流程

数据流入 → Schema.parse(data) → 校验通过 → 返回类型安全的数据
                        ↓ 校验失败
                   抛出错误 / 返回Either结果

类型推导流程:
1. 定义Schema: const UserSchema = z.object({...})
2. 推导类型: type User = z.infer<typeof UserSchema>
3. 运行时校验: UserSchema.parse(apiResponse)
4. 编译期类型: parse返回值自动获得User类型

JSON Schema流程:
1. 定义Schema: const UserSchema = Type.Object({...})
2. 生成JSON Schema: Value.Check(UserSchema, data)
3. 类型推导: type User = Static<typeof UserSchema>
4. 跨语言消费: JSON Schema可被任何语言验证

问题分析:运行时类型校验的5大挑战

  1. 类型擦除陷阱:TypeScript编译后类型信息完全消失,JSON.parse()、API响应、第三方库返回值无法保证类型安全
  2. Schema与类型割裂:手动维护interface和校验逻辑两份代码,极易不一致,改了interface忘了改校验
  3. 错误处理不统一:不同校验库的错误格式各异,ZodError、io-ts的Either、ajv的ValidationError,难以统一处理
  4. 性能与包体积权衡:Zod功能强大但体积较大,io-ts学习曲线陡峭,valibot轻量但生态不成熟
  5. 跨语言互操作:微服务架构下前后端、不同服务间需要共享数据契约,JSON Schema成为唯一通用语言

分步实操:6种生产模式

模式1:Zod Schema定义与类型推导

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
  avatar: z.string().url().optional(),
  createdAt: z.date(),
  tags: z.array(z.string()).default([]),
  metadata: z.record(z.string(), z.unknown()).default({}),
});

type User = z.infer<typeof UserSchema>;

const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
type CreateUserInput = z.infer<typeof CreateUserSchema>;

const UpdateUserSchema = UserSchema.partial().omit({ id: true, createdAt: true });
type UpdateUserInput = z.infer<typeof UpdateUserSchema>;

const rawInput = {
  name: '张三',
  email: 'zhang@example.com',
  age: 28,
  role: 'admin' as const,
  tags: ['typescript'],
  metadata: {},
};

const result = CreateUserSchema.safeParse(rawInput);

if (result.success) {
  const user: CreateUserInput = result.data;
  console.log('校验通过:', user.name, user.email);
} else {
  console.error('校验失败:', result.error.flatten());
}

const BrandedUserId = z.string().uuid().brand<'UserId'>();
type UserId = z.infer<typeof BrandedUserId>;

function getUser(id: UserId): User | null {
  return null;
}

const rawId = '550e8400-e29b-41d4-a716-446655440000';
const userId = BrandedUserId.parse(rawId);
getUser(userId);

const PaginationSchema = z.object({
  page: z.number().int().positive().default(1),
  pageSize: z.number().int().min(1).max(100).default(20),
  sortBy: z.string().optional(),
  sortOrder: z.enum(['asc', 'desc']).default('asc'),
});

type Pagination = z.infer<typeof PaginationSchema>;

const SearchParamsSchema = PaginationSchema.extend({
  keyword: z.string().min(1),
  filters: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
});

type SearchParams = z.infer<typeof SearchParamsSchema>;

const ApiResponseSchema = z.object({
  code: z.number(),
  message: z.string(),
  data: z.unknown(),
  timestamp: z.number(),
});

function validateApiResponse<T>(schema: z.ZodSchema<T>, response: unknown): T {
  const baseResult = ApiResponseSchema.parse(response);
  return schema.parse(baseResult.data);
}

const UserListResponseSchema = z.array(UserSchema);
const users = validateApiResponse(UserListResponseSchema, {
  code: 0,
  message: 'ok',
  data: [],
  timestamp: Date.now(),
});

模式2:TypeBox JSON Schema生成

import { Type, Static, TSchema } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';

const UserSchema = Type.Object({
  id: Type.String({ format: 'uuid' }),
  name: Type.String({ minLength: 2, maxLength: 50 }),
  email: Type.String({ format: 'email' }),
  age: Type.Integer({ minimum: 0, maximum: 150 }),
  role: Type.Union([Type.Literal('admin'), Type.Literal('editor'), Type.Literal('viewer')]),
  avatar: Type.Optional(Type.String({ format: 'uri' })),
  createdAt: Type.String({ format: 'date-time' }),
  tags: Type.Array(Type.String()),
  metadata: Type.Record(Type.String(), Type.Unknown()),
});

type User = Static<typeof UserSchema>;

const CreateUserSchema = Type.Omit(UserSchema, ['id', 'createdAt']);
type CreateUserInput = Static<typeof CreateUserSchema>;

const UpdateUserSchema = Type.Partial(Type.Omit(UserSchema, ['id', 'createdAt']));
type UpdateUserInput = Static<typeof UpdateUserSchema>;

const rawInput: unknown = {
  id: '550e8400-e29b-41d4-a716-446655440000',
  name: '张三',
  email: 'zhang@example.com',
  age: 28,
  role: 'admin',
  createdAt: new Date().toISOString(),
  tags: ['typescript'],
  metadata: {},
};

if (Value.Check(UserSchema, rawInput)) {
  const user: User = Value.Cast(UserSchema, rawInput);
  console.log('校验通过:', user.name);
} else {
  const errors = [...Value.Errors(UserSchema, rawInput)];
  console.error('校验失败:', errors);
}

const PaginationSchema = Type.Object({
  page: Type.Number({ minimum: 1, default: 1 }),
  pageSize: Type.Number({ minimum: 1, maximum: 100, default: 20 }),
  sortBy: Type.Optional(Type.String()),
  sortOrder: Type.Union([Type.Literal('asc'), Type.Literal('desc')]),
});

type Pagination = Static<typeof PaginationSchema>;

const jsonSchemaString = JSON.stringify(UserSchema, null, 2);
console.log('JSON Schema输出:', jsonSchemaString);

const ErrorResponseSchema = Type.Object({
  code: Type.Number(),
  message: Type.String(),
  details: Type.Optional(Type.Array(Type.Object({
    field: Type.String(),
    message: Type.String(),
  }))),
});

type ErrorResponse = Static<typeof ErrorResponseSchema>;

function createApiRoute<T extends TSchema>(
  method: 'GET' | 'POST' | 'PUT' | 'DELETE',
  path: string,
  bodySchema: T,
  responseSchema: TSchema,
) {
  return {
    method,
    path,
    schema: {
      body: bodySchema,
      response: {
        200: responseSchema,
        400: ErrorResponseSchema,
      },
    },
  };
}

const createUserRoute = createApiRoute(
  'POST',
  '/api/users',
  CreateUserSchema,
  UserSchema,
);

const defaults = Value.Create(PaginationSchema);
console.log('默认值:', defaults);

模式3:io-ts Either模式错误处理

import * as t from 'io-ts';
import { either, isRight, isLeft, fold } from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

const UserCodec = t.type({
  id: t.string,
  name: t.string,
  email: t.string,
  age: t.number,
  role: t.union([t.literal('admin'), t.literal('editor'), t.literal('viewer')]),
  avatar: t.union([t.string, t.undefined]),
  tags: t.array(t.string),
  metadata: t.record(t.string, t.unknown),
});

type User = t.TypeOf<typeof UserCodec>;

const CreateUserCodec = t.type({
  name: t.string,
  email: t.string,
  age: t.number,
  role: t.union([t.literal('admin'), t.literal('editor'), t.literal('viewer')]),
  tags: t.array(t.string),
  metadata: t.record(t.string, t.unknown),
});

type CreateUserInput = t.TypeOf<typeof CreateUserCodec>;

const rawInput: unknown = {
  name: '张三',
  email: 'zhang@example.com',
  age: 28,
  role: 'admin',
  tags: ['typescript'],
  metadata: {},
};

const decodeResult = CreateUserCodec.decode(rawInput);

pipe(
  decodeResult,
  fold(
    (errors) => {
      const formatted = errors.map((err) => ({
        path: err.context.map((c) => c.key).filter(Boolean).join('.'),
        expected: err.context[err.context.length - 1]?.type.name,
        received: typeof err.value,
      }));
      console.error('解码失败:', formatted);
    },
    (data) => {
      console.log('解码成功:', data.name);
    },
  ),
);

const EmailCodec = new t.Type<string, string, unknown>(
  'Email',
  t.string.is,
  (u, c) =>
    either.chain(t.string.validate(u, c), (s) => {
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      return emailRegex.test(s) ? t.success(s) : t.failure(u, c, 'Invalid email format');
    }),
  t.string.encode,
);

const PositiveIntCodec = new t.Type<number, number, unknown>(
  'PositiveInt',
  t.number.is,
  (u, c) =>
    either.chain(t.number.validate(u, c), (n) => {
      return Number.isInteger(n) && n > 0 ? t.success(n) : t.failure(u, c, 'Must be a positive integer');
    }),
  t.number.encode,
);

const StrictUserCodec = t.type({
  id: t.string,
  name: t.string,
  email: EmailCodec,
  age: PositiveIntCodec,
  role: t.union([t.literal('admin'), t.literal('editor'), t.literal('viewer')]),
  tags: t.array(t.string),
});

type StrictUser = t.TypeOf<typeof StrictUserCodec>;

const PaginatedResponseCodec = <C extends t.Mixed>(codec: C) =>
  t.type({
    items: t.array(codec),
    total: t.number,
    page: t.number,
    pageSize: t.number,
  });

const UserListResponseCodec = PaginatedResponseCodec(UserCodec);
type UserListResponse = t.TypeOf<typeof UserListResponseCodec>;

const apiResponse: unknown = {
  items: [],
  total: 0,
  page: 1,
  pageSize: 20,
};

const listResult = UserListResponseCodec.decode(apiResponse);
if (isRight(listResult)) {
  console.log('列表解码成功,总数:', listResult.right.total);
}

function validateOrThrow<C extends t.Mixed>(codec: C, data: unknown): t.TypeOf<C> {
  const result = codec.decode(data);
  if (isLeft(result)) {
    const messages = result.left.map(
      (e) => `${e.context.map((c) => c.key).filter(Boolean).join('.')}: expected ${e.context[e.context.length - 1]?.type.name}`,
    );
    throw new Error(`Validation failed: ${messages.join('; ')}`);
  }
  return result.right;
}

const user = validateOrThrow(UserCodec, rawInput);

模式4:valibot轻量校验

import * as v from 'valibot';

const UserSchema = v.object({
  id: v.pipe(v.string(), v.uuid()),
  name: v.pipe(v.string(), v.minLength(2), v.maxLength(50)),
  email: v.pipe(v.string(), v.email()),
  age: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(150)),
  role: v.picklist(['admin', 'editor', 'viewer']),
  avatar: v.optional(v.pipe(v.string(), v.url())),
  createdAt: v.date(),
  tags: v.optional(v.array(v.string()), []),
  metadata: v.optional(v.record(v.string(), v.unknown()), {}),
});

type User = v.InferOutput<typeof UserSchema>;

const CreateUserSchema = v.omit(UserSchema, ['id', 'createdAt']);
type CreateUserInput = v.InferOutput<typeof CreateUserSchema>;

const UpdateUserSchema = v.partial(v.omit(UserSchema, ['id', 'createdAt']));
type UpdateUserInput = v.InferOutput<typeof UpdateUserSchema>;

const rawInput = {
  name: '张三',
  email: 'zhang@example.com',
  age: 28,
  role: 'admin' as const,
  tags: ['typescript'],
  metadata: {},
};

const result = v.safeParse(CreateUserSchema, rawInput);

if (result.success) {
  const user: CreateUserInput = result.output;
  console.log('校验通过:', user.name);
} else {
  console.error('校验失败:', result.issues.map((issue) => ({
    path: issue.path?.map((p) => p.key).join('.'),
    message: issue.message,
  })));
}

const EmailSchema = v.pipe(
  v.string(),
  v.email('邮箱格式不正确'),
  v.toLowerCase(),
);

const PositiveIntSchema = v.pipe(
  v.number(),
  v.integer('必须是整数'),
  v.minValue(1, '必须是正整数'),
);

const PaginationSchema = v.object({
  page: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
  pageSize: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)), 20),
  sortBy: v.optional(v.string()),
  sortOrder: v.optional(v.picklist(['asc', 'desc']), 'asc'),
});

type Pagination = v.InferOutput<typeof PaginationSchema>;

const SearchParamsSchema = v.intersect([
  PaginationSchema,
  v.object({
    keyword: v.pipe(v.string(), v.minLength(1)),
    filters: v.optional(v.record(v.string(), v.union([v.string(), v.number(), v.boolean()])), {}),
  }),
]);

type SearchParams = v.InferOutput<typeof SearchParamsSchema>;

const DateRangeSchema = v.object({
  start: v.pipe(v.string(), v.isoTimestamp()),
  end: v.pipe(v.string(), v.isoTimestamp()),
});

const DateRangeWithCheck = v.pipe(
  DateRangeSchema,
  v.check(
    (input) => new Date(input.start) < new Date(input.end),
    '开始日期必须早于结束日期',
  ),
);

const rawDateRange = { start: '2026-01-01T00:00:00Z', end: '2026-12-31T23:59:59Z' };
const dateResult = v.safeParse(DateRangeWithCheck, rawDateRange);
if (dateResult.success) {
  console.log('日期范围校验通过');
}

const TransformSchema = v.pipe(
  v.string(),
  v.trim(),
  v.transform((s) => parseInt(s, 10)),
  v.number(),
  v.minValue(0),
  v.maxValue(100),
);

const scoreResult = v.parse(TransformSchema, ' 85 ');
console.log('转换结果:', scoreResult, typeof scoreResult);

模式5:tRPC端到端类型安全

import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.context<Context>().create();

const router = t.router;
const publicProcedure = t.procedure;

interface Context {
  userId: string | null;
  requestId: string;
}

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
  avatar: z.string().url().optional(),
  createdAt: z.date(),
  tags: z.array(z.string()).default([]),
});

type User = z.infer<typeof UserSchema>;

const CreateUserInput = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
  tags: z.array(z.string()).optional().default([]),
});

const UpdateUserInput = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(50).optional(),
  email: z.string().email().optional(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(['admin', 'editor', 'viewer']).optional(),
});

const PaginationInput = z.object({
  page: z.number().int().positive().default(1),
  pageSize: z.number().int().min(1).max(100).default(20),
  sortBy: z.enum(['name', 'email', 'age', 'createdAt']).optional(),
  sortOrder: z.enum(['asc', 'desc']).default('asc'),
});

const userRouter = router({
  list: publicProcedure
    .input(PaginationInput)
    .query(async ({ input }) => {
      const { page, pageSize, sortBy, sortOrder } = input;
      return {
        items: [] as User[],
        total: 0,
        page,
        pageSize,
      };
    }),

  getById: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input }) => {
      return null as User | null;
    }),

  create: publicProcedure
    .input(CreateUserInput)
    .mutation(async ({ input, ctx }) => {
      if (!ctx.userId) {
        throw new TRPCError({
          code: 'UNAUTHORIZED',
          message: 'Must be logged in to create users',
        });
      }
      const newUser: User = {
        id: crypto.randomUUID(),
        ...input,
        avatar: undefined,
        createdAt: new Date(),
      };
      return newUser;
    }),

  update: publicProcedure
    .input(UpdateUserInput)
    .mutation(async ({ input, ctx }) => {
      if (!ctx.userId) {
        throw new TRPCError({ code: 'UNAUTHORIZED' });
      }
      const { id, ...updates } = input;
      return { id, ...updates, createdAt: new Date() } as User;
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ input }) => {
      return { success: true, id: input.id };
    }),
});

const appRouter = router({
  user: userRouter,
});

type AppRouter = typeof appRouter;

export { appRouter, type AppRouter };
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';

const client = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/trpc',
    }),
  ],
});

async function demo() {
  const users = await client.user.list.query({
    page: 1,
    pageSize: 10,
    sortBy: 'name',
    sortOrder: 'asc',
  });

  const user = await client.user.getById.query({
    id: '550e8400-e29b-41d4-a716-446655440000',
  });

  const created = await client.user.create.mutate({
    name: '张三',
    email: 'zhang@example.com',
    age: 28,
    role: 'admin',
  });

  const updated = await client.user.update.mutate({
    id: created.id,
    name: '李四',
  });

  await client.user.delete.mutate({
    id: created.id,
  });
}

模式6:生产API校验中间件

import { z, ZodSchema, ZodError } from 'zod';
import { Request, Response, NextFunction } from 'express';

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

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({
          code: 400,
          message: 'Validation failed',
          details: error.errors.map((e) => ({
            path: e.path.join('.'),
            message: e.message,
            code: e.code,
          })),
        });
        return;
      }
      next(error);
    }
  };
}

const CreateUserBody = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
  tags: z.array(z.string()).optional().default([]),
});

const PaginationQuery = z.object({
  page: z.coerce.number().int().positive().default(1),
  pageSize: z.coerce.number().int().min(1).max(100).default(20),
  sortBy: z.string().optional(),
  sortOrder: z.enum(['asc', 'desc']).default('asc'),
});

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

app.post('/api/users', validate({ body: CreateUserBody }), (req, res) => {
  const body: z.infer<typeof CreateUserBody> = req.body;
  res.json({ code: 0, data: body });
});

app.get('/api/users', validate({ query: PaginationQuery }), (req, res) => {
  const query: z.infer<typeof PaginationQuery> = req.query as any;
  res.json({ code: 0, data: { items: [], ...query } });
});

app.get('/api/users/:id', validate({ params: UserIdParams }), (req, res) => {
  const { id }: z.infer<typeof UserIdParams> = req.params as any;
  res.json({ code: 0, data: { id } });
});

interface ApiError {
  code: number;
  message: string;
  details?: Array<{ path: string; message: string; code: string }>;
}

function formatZodError(error: ZodError): ApiError {
  return {
    code: 400,
    message: 'Validation failed',
    details: error.errors.map((e) => ({
      path: e.path.join('.'),
      message: e.message,
      code: e.code,
    })),
  };
}

const errorMap: z.ZodErrorMap = (issue, ctx) => {
  switch (issue.code) {
    case z.ZodIssueCode.invalid_type:
      return { message: `期望 ${issue.expected},收到 ${issue.received}` };
    case z.ZodIssueCode.too_small:
      return { message: `值不能小于 ${issue.minimum}` };
    case z.ZodIssueCode.too_big:
      return { message: `值不能大于 ${issue.maximum}` };
    case z.ZodIssueCode.invalid_string:
      if (issue.validation === 'email') return { message: '邮箱格式不正确' };
      if (issue.validation === 'uuid') return { message: 'UUID格式不正确' };
      if (issue.validation === 'url') return { message: 'URL格式不正确' };
      return { message: '字符串格式不正确' };
    default:
      return { message: ctx.defaultError };
  }
};

z.setErrorMap(errorMap);

const RateLimitSchema = z.object({
  windowMs: z.number().int().positive().default(60000),
  maxRequests: z.number().int().positive().default(100),
});

function rateLimiter(options: unknown) {
  const config = RateLimitSchema.parse(options);
  const hits = new Map<string, number[]>();

  return (req: Request, res: Response, next: NextFunction) => {
    const key = req.ip ?? 'unknown';
    const now = Date.now();
    const windowStart = now - config.windowMs;
    const userHits = (hits.get(key) ?? []).filter((t) => t > windowStart);

    if (userHits.length >= config.maxRequests) {
      res.status(429).json({ code: 429, message: 'Too many requests' });
      return;
    }

    userHits.push(now);
    hits.set(key, userHits);
    next();
  };
}

常见陷阱

陷阱1:信任API响应不校验

// ❌ 直接使用API响应,类型断言掩盖了运行时风险
const response = await fetch('/api/users');
const users = (await response.json()) as User[];

// ✅ 使用Schema校验API响应
const response = await fetch('/api/users');
const raw = await response.json();
const result = z.array(UserSchema).safeParse(raw);
if (!result.success) {
  throw new Error(`API响应校验失败: ${result.error.message}`);
}
const users: User[] = result.data;

陷阱2:Schema与Interface重复定义

// ❌ 维护两份定义,极易不一致
interface User {
  id: string;
  name: string;
  email: string;
}
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2),
  email: z.string().email(),
});

// ✅ 从Schema推导类型,单一数据源
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2),
  email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;

陷阱3:忽略safeParse直接parse

// ❌ parse抛出异常,未捕获会导致应用崩溃
const user = UserSchema.parse(unknownData);

// ✅ safeParse返回结果对象,安全处理校验失败
const result = UserSchema.safeParse(unknownData);
if (result.success) {
  const user = result.data;
} else {
  console.error('校验失败:', result.error.flatten());
}

陷阱4:JSON.parse后不做校验

// ❌ JSON.parse返回any,类型断言不提供运行时保障
const config = JSON.parse(localStorage.getItem('appConfig')!) as AppConfig;

// ✅ JSON.parse后用Schema校验
const raw = localStorage.getItem('appConfig');
if (raw) {
  const result = AppConfigSchema.safeParse(JSON.parse(raw));
  if (result.success) {
    const config: AppConfig = result.data;
  }
}

陷阱5:过度校验导致性能问题

// ❌ 在热路径上使用复杂的嵌套Schema校验
function processItem(item: unknown) {
  const validated = DeepNestedSchema.parse(item); // 每次调用都完整校验
}

// ✅ 缓存Schema,使用宽松校验+严格校验分层
const LightItemSchema = z.object({ id: z.string(), type: z.string() });
const StrictItemSchema = z.object({
  id: z.string().uuid(),
  type: z.enum(['a', 'b', 'c']),
  data: z.record(z.string(), z.unknown()),
  metadata: z.array(MetadataSchema),
});

function processItem(item: unknown) {
  const light = LightItemSchema.parse(item);
  if (needsStrictValidation(light.type)) {
    return StrictItemSchema.parse(item);
  }
  return light;
}

错误排查表

错误现象 可能原因 解决方案
ZodError: Expected string, received undefined 可选字段未标记.optional() 添加.optional().default()
Type 'string' is not assignable to type '...' Schema推导类型与手动类型冲突 删除手动interface,统一用z.infer
io-ts decode returns Left with empty context 输入值类型与Codec不匹配 检查err.context链路定位具体字段
Value.Check returns false but no errors TypeBox校验失败但错误信息被吞 使用Value.Errors()获取详细错误
valibot safeParse returns success but output is wrong .transform()改变了输出类型但未调整Schema 检查InferOutputInferInput的差异
tRPC input validation not triggering procedure未链式调用.input() 确保.input().query()/.mutation()之前
Schema.parse() throws instead of returning result 使用了parse()而非safeParse() 改用safeParse()处理不确定输入
z.coerce not converting query params Express query参数为string[]类型 使用z.preprocess()手动转换
Branded type lost after serialization Branded类型在JSON序列化后丢失 在反序列化时重新parse()恢复Brand
Circular schema reference Schema互相引用导致循环 使用z.lazy()Type.Rec()处理递归结构

高级优化

Schema复用与组合

import { z } from 'zod';

const BaseFields = z.object({
  id: z.string().uuid(),
  createdAt: z.date(),
  updatedAt: z.date(),
});

const UserName = z.string().min(2).max(50);
const UserEmail = z.string().email();

const CreateUserSchema = z.object({
  name: UserName,
  email: UserEmail,
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
});

const UserSchema = BaseFields.merge(CreateUserSchema);
type User = z.infer<typeof UserSchema>;

const DiscriminatedUnionSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('email'),
    address: z.string().email(),
    subject: z.string(),
  }),
  z.object({
    type: z.literal('sms'),
    phone: z.string().regex(/^\+?\d{10,15}$/),
    message: z.string().max(160),
  }),
  z.object({
    type: z.literal('push'),
    deviceId: z.string().uuid(),
    title: z.string(),
    body: z.string(),
  }),
]);

type Notification = z.infer<typeof DiscriminatedUnionSchema>;

function sendNotification(notification: Notification) {
  switch (notification.type) {
    case 'email':
      console.log('发送邮件到:', notification.address);
      break;
    case 'sms':
      console.log('发送短信到:', notification.phone);
      break;
    case 'push':
      console.log('推送通知到:', notification.deviceId);
      break;
  }
}

const RecursiveCommentSchema: z.ZodType<Comment> = z.lazy(() =>
  z.object({
    id: z.string().uuid(),
    content: z.string(),
    author: z.string(),
    replies: z.array(RecursiveCommentSchema).default([]),
  }),
);

interface Comment {
  id: string;
  content: string;
  author: string;
  replies: Comment[];
}

条件校验与跨字段联动

import { z } from 'zod';

const PaymentSchema = z.object({
  method: z.enum(['credit_card', 'bank_transfer', 'crypto']),
  amount: z.number().positive(),
  currency: z.string().length(3),
}).refine(
  (data) => {
    if (data.method === 'crypto' && data.currency !== 'BTC' && data.currency !== 'ETH') {
      return false;
    }
    return true;
  },
  { message: '加密货币支付仅支持BTC或ETH', path: ['currency'] },
);

const RegistrationSchema = z.object({
  username: z.string().min(3).max(20),
  password: z.string().min(8),
  confirmPassword: z.string(),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  parentConsent: z.boolean().optional(),
}).refine(
  (data) => data.password === data.confirmPassword,
  { message: '两次输入的密码不一致', path: ['confirmPassword'] },
).refine(
  (data) => {
    if (data.age < 18) {
      return data.parentConsent === true;
    }
    return true;
  },
  { message: '未成年人需要家长同意', path: ['parentConsent'] },
);

type Registration = z.infer<typeof RegistrationSchema>;

const ShippingSchema = z.object({
  country: z.string(),
  province: z.string(),
  city: z.string(),
  address: z.string().min(5),
  zipCode: z.string(),
}).refine(
  (data) => {
    if (data.country === 'CN') {
      return /^\d{6}$/.test(data.zipCode);
    }
    if (data.country === 'US') {
      return /^\d{5}(-\d{4})?$/.test(data.zipCode);
    }
    return true;
  },
  { message: '邮编格式不正确', path: ['zipCode'] },
);

性能优化与缓存策略

import { z } from 'zod';

const cachedSchemas = new Map<string, z.ZodSchema>();

function getOrCreateSchema<T>(key: string, factory: () => z.ZodSchema<T>): z.ZodSchema<T> {
  if (!cachedSchemas.has(key)) {
    cachedSchemas.set(key, factory());
  }
  return cachedSchemas.get(key) as z.ZodSchema<T>;
}

const DynamicFilterSchema = getOrCreateSchema('dynamicFilter', () =>
  z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
);

const BatchValidationSchema = z.array(z.unknown());

function batchValidate<T>(schema: z.ZodSchema<T>, items: unknown[]): {
  valid: T[];
  invalid: Array<{ index: number; error: z.ZodError }>;
} {
  const valid: T[] = [];
  const invalid: Array<{ index: number; error: z.ZodError }> = [];

  for (let i = 0; i < items.length; i++) {
    const result = schema.safeParse(items[i]);
    if (result.success) {
      valid.push(result.data);
    } else {
      invalid.push({ index: i, error: result.error });
    }
  }

  return { valid, invalid };
}

const LightUserSchema = z.object({
  id: z.string(),
  name: z.string(),
});

const FullUserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'editor', 'viewer']),
  tags: z.array(z.string()),
  metadata: z.record(z.string(), z.unknown()),
});

function validateUserLevel(data: unknown, strict: boolean) {
  const schema = strict ? FullUserSchema : LightUserSchema;
  return schema.safeParse(data);
}

const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

function loadEnv() {
  const result = EnvSchema.safeParse(process.env);
  if (!result.success) {
    console.error('环境变量校验失败:', result.error.flatten());
    process.exit(1);
  }
  return result.data;
}

const env = loadEnv();
console.log('环境配置加载成功:', env.NODE_ENV, env.PORT);

方案对比

特性 Zod TypeBox io-ts valibot ajv
包体积 ~13KB ~12KB ~8KB ~3KB ~30KB
类型推导 z.infer<> Static<> t.TypeOf<> v.InferOutput<> 无原生支持
JSON Schema输出 需zod-to-json-schema 原生输出 需io-ts-to-json-schema 需valibot-to-json-schema 原生消费
错误处理 ZodError Value.Errors Either issues数组 ValidationError
函数式风格 是(fp-ts)
异步校验 .refine() async .check() async 自定义keyword
学习曲线
生态成熟度 ★★★★★ ★★★★ ★★★ ★★★ ★★★★★
适用场景 通用首选 JSON Schema互操作 函数式项目 轻量/浏览器 JSON Schema标准校验

运行时类型校验不是"锦上添花",而是TypeScript生产应用的"安全网"。 选择Zod获得最佳开发体验,选择TypeBox对接JSON Schema生态,选择io-ts拥抱函数式编程,选择valibot追求极致轻量,选择ajv获得JSON Schema标准合规。无论选哪个,永远不要信任未经校验的外部数据——这是2026年TypeScript工程师的基本素养。


推荐工具

  • JSON格式化 — 校验和格式化API响应JSON,快速排查数据结构问题
  • Base64编解码 — 解码JWT Token载荷,校验Token中的类型数据
  • 哈希计算 — 计算Schema指纹,检测API契约变更

本站提供浏览器本地工具,免注册即可试用 →

#TypeScript#运行时校验#Zod#TypeBox#类型安全#2026#io-ts