TypeScript Runtime Type Checking: 6 Production Patterns from Zod to TypeBox

前端工程

Your TypeScript Types Vanish at Runtime

You carefully define interface User, TypeScript compiles with zero errors, yet production blows up — because the API returned null instead of string, the third-party SDK's type declarations don't match its actual behavior, and JSON.parse() always returns any. TypeScript types are completely erased after compilation; there's zero runtime guarantee. In 2026, runtime type checking has evolved from "nice-to-have" to "must-have" — Zod, TypeBox, io-ts, and valibot each have their strengths, and tRPC has pushed end-to-end type safety to new heights.

This article starts from core concepts and guides you through Zod schema definition → TypeBox JSON Schema generation → io-ts Either error handling → valibot lightweight validation → tRPC end-to-end type safety → production API validation middleware with 6 production patterns, from selection to implementation.


Core Concepts

Concept Description
Runtime Type Checking Validating data structures at runtime,弥补ing TypeScript's type erasure gap
Schema Validation Declaratively defining data structures, providing both validation logic and type inference
Type Inference Auto-deriving TypeScript types from schemas, eliminating duplicate maintenance of interfaces and validation
JSON Schema Standardized data description format, consumable by multiple tools and languages
Branded Types Distinguishing structurally identical types through unique markers (e.g., UserId vs OrderId)
Decoding Combined operation of validation + transformation, decoding raw input into type-safe output

Validation Flow

Data input → Schema.parse(data) → Validation passes → Returns type-safe data
                        ↓ Validation fails
                   Throws error / Returns Either result

Type inference flow:
1. Define schema: const UserSchema = z.object({...})
2. Infer type: type User = z.infer<typeof UserSchema>
3. Runtime validation: UserSchema.parse(apiResponse)
4. Compile-time type: parse return value automatically gets User type

JSON Schema flow:
1. Define schema: const UserSchema = Type.Object({...})
2. Generate JSON Schema: Value.Check(UserSchema, data)
3. Type inference: type User = Static<typeof UserSchema>
4. Cross-language consumption: JSON Schema can be validated by any language

Problem Analysis: 5 Major Runtime Type Checking Challenges

  1. Type erasure trap: TypeScript type information completely disappears after compilation; JSON.parse(), API responses, and third-party library return values cannot guarantee type safety
  2. Schema-type split: Manually maintaining both interfaces and validation logic leads to inconsistencies — update the interface, forget the validation
  3. Inconsistent error handling: Different validation libraries have different error formats — ZodError, io-ts Either, ajv ValidationError — making unified handling difficult
  4. Performance vs bundle size tradeoff: Zod is powerful but large, io-ts has a steep learning curve, valibot is lightweight but its ecosystem is immature
  5. Cross-language interoperability: Microservice architectures need shared data contracts between frontend and backend, different services; JSON Schema is the only universal language

Step-by-Step: 6 Production Patterns

Pattern 1: Zod Schema Definition and Type Inference

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: 'John Doe',
  email: 'john@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('Validation passed:', user.name, user.email);
} else {
  console.error('Validation failed:', 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(),
});

Pattern 2: TypeBox JSON Schema Generation

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: 'John Doe',
  email: 'john@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('Validation passed:', user.name);
} else {
  const errors = [...Value.Errors(UserSchema, rawInput)];
  console.error('Validation failed:', 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 output:', 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('Default values:', defaults);

Pattern 3: io-ts Either-Based Error Handling

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: 'John Doe',
  email: 'john@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('Decode failed:', formatted);
    },
    (data) => {
      console.log('Decode succeeded:', 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('List decode succeeded, total:', 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);

Pattern 4: valibot Lightweight Validation

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: 'John Doe',
  email: 'john@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('Validation passed:', user.name);
} else {
  console.error('Validation failed:', result.issues.map((issue) => ({
    path: issue.path?.map((p) => p.key).join('.'),
    message: issue.message,
  })));
}

const EmailSchema = v.pipe(
  v.string(),
  v.email('Invalid email format'),
  v.toLowerCase(),
);

const PositiveIntSchema = v.pipe(
  v.number(),
  v.integer('Must be an integer'),
  v.minValue(1, 'Must be a positive integer'),
);

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),
    'Start date must be before end date',
  ),
);

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('Date range validation passed');
}

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('Transform result:', scoreResult, typeof scoreResult);

Pattern 5: tRPC End-to-End Type Safety

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: 'John Doe',
    email: 'john@example.com',
    age: 28,
    role: 'admin',
  });

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

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

Pattern 6: Production API Validation Middleware

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: `Expected ${issue.expected}, received ${issue.received}` };
    case z.ZodIssueCode.too_small:
      return { message: `Value must not be less than ${issue.minimum}` };
    case z.ZodIssueCode.too_big:
      return { message: `Value must not be greater than ${issue.maximum}` };
    case z.ZodIssueCode.invalid_string:
      if (issue.validation === 'email') return { message: 'Invalid email format' };
      if (issue.validation === 'uuid') return { message: 'Invalid UUID format' };
      if (issue.validation === 'url') return { message: 'Invalid URL format' };
      return { message: 'Invalid string format' };
    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();
  };
}

Common Pitfalls

Pitfall 1: Trusting API Responses Without Validation

// ❌ Using API response directly, type assertion masks runtime risks
const response = await fetch('/api/users');
const users = (await response.json()) as User[];

// ✅ Validate API response with schema
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 response validation failed: ${result.error.message}`);
}
const users: User[] = result.data;

Pitfall 2: Duplicate Schema and Interface Definitions

// ❌ Maintaining two definitions, easily becoming inconsistent
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(),
});

// ✅ Derive types from schema, single source of truth
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2),
  email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;

Pitfall 3: Using parse Instead of safeParse

// ❌ parse throws exceptions, uncaught errors crash the app
const user = UserSchema.parse(unknownData);

// ✅ safeParse returns a result object, safely handles validation failure
const result = UserSchema.safeParse(unknownData);
if (result.success) {
  const user = result.data;
} else {
  console.error('Validation failed:', result.error.flatten());
}

Pitfall 4: Skipping Validation After JSON.parse

// ❌ JSON.parse returns any, type assertion provides no runtime guarantee
const config = JSON.parse(localStorage.getItem('appConfig')!) as AppConfig;

// ✅ Validate after JSON.parse with schema
const raw = localStorage.getItem('appConfig');
if (raw) {
  const result = AppConfigSchema.safeParse(JSON.parse(raw));
  if (result.success) {
    const config: AppConfig = result.data;
  }
}

Pitfall 5: Over-Validation Causing Performance Issues

// ❌ Using complex nested schema validation on hot paths
function processItem(item: unknown) {
  const validated = DeepNestedSchema.parse(item); // Full validation every call
}

// ✅ Cache schemas, use layered validation (loose + strict)
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;
}

Error Troubleshooting Table

Error Symptom Possible Cause Solution
ZodError: Expected string, received undefined Optional field not marked .optional() Add .optional() or .default()
Type 'string' is not assignable to type '...' Schema-inferred type conflicts with manual type Remove manual interface, use z.infer consistently
io-ts decode returns Left with empty context Input type doesn't match Codec Check err.context chain to locate specific field
Value.Check returns false but no errors TypeBox validation failed but errors were swallowed Use Value.Errors() to get detailed errors
valibot safeParse returns success but output is wrong .transform() changed output type without adjusting schema Check difference between InferOutput and InferInput
tRPC input validation not triggering Procedure not chaining .input() Ensure .input() is called before .query()/.mutation()
Schema.parse() throws instead of returning result Using parse() instead of safeParse() Switch to safeParse() for uncertain inputs
z.coerce not converting query params Express query params are string[] type Use z.preprocess() for manual conversion
Branded type lost after serialization Branded types lost after JSON serialization Re-parse() on deserialization to restore Brand
Circular schema reference Schemas referencing each other causing cycles Use z.lazy() or Type.Rec() for recursive structures

Advanced Optimization

Schema Reuse and Composition

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('Sending email to:', notification.address);
      break;
    case 'sms':
      console.log('Sending SMS to:', notification.phone);
      break;
    case 'push':
      console.log('Push notification to:', 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[];
}

Conditional Validation and Cross-Field Dependencies

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: 'Crypto payments only support BTC or 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: 'Passwords do not match', path: ['confirmPassword'] },
).refine(
  (data) => {
    if (data.age < 18) {
      return data.parentConsent === true;
    }
    return true;
  },
  { message: 'Minors require parental consent', 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 === 'US') {
      return /^\d{5}(-\d{4})?$/.test(data.zipCode);
    }
    if (data.country === 'UK') {
      return /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/.test(data.zipCode);
    }
    return true;
  },
  { message: 'Invalid zip code format', path: ['zipCode'] },
);

Performance Optimization and Caching Strategies

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('Environment variable validation failed:', result.error.flatten());
    process.exit(1);
  }
  return result.data;
}

const env = loadEnv();
console.log('Environment config loaded:', env.NODE_ENV, env.PORT);

Comparison

Feature Zod TypeBox io-ts valibot ajv
Bundle size ~13KB ~12KB ~8KB ~3KB ~30KB
Type inference z.infer<> Static<> t.TypeOf<> v.InferOutput<> No native support
JSON Schema output Requires zod-to-json-schema Native output Requires io-ts-to-json-schema Requires valibot-to-json-schema Native consumer
Error handling ZodError Value.Errors Either issues array ValidationError
Functional style No No Yes (fp-ts) No No
Async validation .refine() async No Yes .check() async Custom keyword
Learning curve Low Low High Low Medium
Ecosystem maturity ★★★★★ ★★★★ ★★★ ★★★ ★★★★★
Best for General-purpose first choice JSON Schema interop Functional projects Lightweight/browser JSON Schema standard validation

Runtime type checking isn't "icing on the cake" — it's the "safety net" for TypeScript production applications. Choose Zod for the best developer experience, TypeBox for JSON Schema ecosystem integration, io-ts for functional programming, valibot for extreme lightweight, and ajv for JSON Schema standard compliance. Whichever you choose, never trust unvalidated external data — this is the basic literacy of a TypeScript engineer in 2026.


  • JSON Formatter — Validate and format API response JSON, quickly troubleshoot data structure issues
  • Base64 Encode/Decode — Decode JWT Token payloads, validate type data in tokens
  • Hash Calculator — Calculate schema fingerprints, detect API contract changes

Try these browser-local tools — no sign-up required →

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