TypeScriptランタイム型チェック実践:ZodからTypeBoxまで6つのプロダクションパターン

前端工程

TypeScriptの型はランタイムでは消えている

完璧なinterface Userを定義し、TypeScriptがエラーゼロでコンパイルしても、本番で爆発する——APIがstringの代わりにnullを返し、サードパーティSDKの型宣言と実際の動作が完全に不一致で、JSON.parse()の戻り値は常にanyTypeScriptの型はコンパイル後に完全に消去され、ランタイムでは一切の保証がない。 2026年、ランタイム型チェックは「オプション」から「マスト」へと進化した——Zod、TypeBox、io-ts、valibotの4つのソリューションがそれぞれ強みを持ち、tRPCはエンドツーエンドの型安全性を新たな高みに押し上げた。

本記事では、コア概念から出発し、Zodスキーマ定義→TypeBox JSON Schema生成→io-ts Eitherエラー処理→valibot軽量バリデーション→tRPCエンドツーエンド型安全性→プロダクションAPIバリデーションミドルウェアの6つのプロダクションパターンを、選定から実装まで一歩ずつ解説する。


コア概念

概念 説明
Runtime Type Checking ランタイムでデータ構造を検証し、TypeScriptの型消去の欠陥を補完
Schema Validation データ構造を宣言的に定義し、バリデーションロジックと型推論の両方を提供
Type Inference スキーマからTypeScriptの型を自動推論し、interfaceとバリデーションロジックの重複保守を排除
JSON Schema 標準化されたデータ記述フォーマット、複数のツールと言語で消費可能
Branded Types 一意のマーカーで同構造の型を区別し、型の混用を防止(UserId vs OrderIdなど)
Decoding バリデーション+変換の組み合わせ操作、生の入力を型安全な出力にデコード

バリデーションフロー

データ入力 → Schema.parse(data) → バリデーション通過 → 型安全なデータを返す
                        ↓ バリデーション失敗
                   エラーをスロー / Either結果を返す

型推論フロー:
1. スキーマ定義: const UserSchema = z.object({...})
2. 型推論: type User = z.infer<typeof UserSchema>
3. ランタイムバリデーション: UserSchema.parse(apiResponse)
4. コンパイル時型: parseの戻り値が自動的にUser型を取得

JSON Schemaフロー:
1. スキーマ定義: 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. スキーマと型の乖離:interfaceとバリデーションロジックを手動で2つ保守すると、不一致になりやすく、interfaceを変更してバリデーションの更新を忘れる
  3. エラー処理の不統一:バリデーションライブラリごとにエラーフォーマットが異なり、ZodError、io-tsのEither、ajvのValidationErrorを統一的に扱うのが困難
  4. パフォーマンスとバンドルサイズのトレードオフ:Zodは高機能だがサイズが大きく、io-tsは学習曲線が急で、valibotは軽量だがエコシステムが未成熟
  5. 言語横断の相互運用性:マイクロサービスアーキテクチャでは、フロントエンドとバックエンド、異なるサービス間でデータコントラクトを共有する必要があり、JSON Schemaが唯一の共通言語

ステップバイステップ:6つのプロダクションパターン

パターン1:Zodスキーマ定義と型推論

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: 'tanaka@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: 'tanaka@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: 'tanaka@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, 'メールアドレスの形式が正しくありません');
    }),
  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, '正の整数である必要があります');
    }),
  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: 'tanaka@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: 'ユーザー作成にはログインが必要です',
        });
      }
      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: 'tanaka@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: 'バリデーションに失敗しました',
          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: 'バリデーションに失敗しました',
    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: 'リクエストが多すぎます' });
      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[];

// ✅ スキーマで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:スキーマとインターフェースの重複定義

// ❌ 2つの定義を保守、不一致になりやすい
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(),
});

// ✅ スキーマから型を推論、単一の真実の情報源
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後にスキーマでバリデーション
const raw = localStorage.getItem('appConfig');
if (raw) {
  const result = AppConfigSchema.safeParse(JSON.parse(raw));
  if (result.success) {
    const config: AppConfig = result.data;
  }
}

落とし穴5:過剰バリデーションによるパフォーマンス問題

// ❌ ホットパスで複雑なネストスキーマバリデーションを使用
function processItem(item: unknown) {
  const validated = DeepNestedSchema.parse(item); // 毎回完全バリデーション
}

// ✅ スキーマをキャッシュ、緩いバリデーション+厳格バリデーションの階層化
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 '...' スキーマ推論型と手動型が競合 手動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()が出力型を変更したがスキーマ未調整 InferOutputInferInputの差異を確認
tRPC input validation not triggering procedureで.input()がチェーンされていない .input().query()/.mutation()の前にあることを確認
Schema.parse() throws instead of returning result safeParse()ではなくparse()を使用 不確実な入力にはsafeParse()に切り替え
z.coerce not converting query params Expressのクエリパラメータがstring[] z.preprocess()で手動変換
Branded type lost after serialization Branded型がJSONシリアライズ後に消失 デシリアライズ時に再度parse()でBrandを復元
Circular schema reference スキーマが相互参照し循環が発生 z.lazy()またはType.Rec()で再帰構造を処理

高度な最適化

スキーマの再利用と合成

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('SMS送信先:', 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 === 'JP') {
      return /^\d{3}-?\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トークンペイロードのデコード、トークン内の型データを検証
  • ハッシュ計算 — スキーマフィンガープリントの計算、APIコントラクトの変更を検出

ブラウザローカルツールを無料で試す →

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