TypeScriptランタイム型チェック実践:ZodからTypeBoxまで6つのプロダクションパターン
前端工程
TypeScriptの型はランタイムでは消えている
完璧なinterface Userを定義し、TypeScriptがエラーゼロでコンパイルしても、本番で爆発する——APIがstringの代わりにnullを返し、サードパーティSDKの型宣言と実際の動作が完全に不一致で、JSON.parse()の戻り値は常にany。TypeScriptの型はコンパイル後に完全に消去され、ランタイムでは一切の保証がない。 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つの課題
- 型消去の罠:TypeScriptコンパイル後、型情報は完全に消失し、
JSON.parse()、APIレスポンス、サードパーティライブラリの戻り値は型安全性を保証できない - スキーマと型の乖離:interfaceとバリデーションロジックを手動で2つ保守すると、不一致になりやすく、interfaceを変更してバリデーションの更新を忘れる
- エラー処理の不統一:バリデーションライブラリごとにエラーフォーマットが異なり、ZodError、io-tsのEither、ajvのValidationErrorを統一的に扱うのが困難
- パフォーマンスとバンドルサイズのトレードオフ:Zodは高機能だがサイズが大きく、io-tsは学習曲線が急で、valibotは軽量だがエコシステムが未成熟
- 言語横断の相互運用性:マイクロサービスアーキテクチャでは、フロントエンドとバックエンド、異なるサービス間でデータコントラクトを共有する必要があり、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()が出力型を変更したがスキーマ未調整 |
InferOutputとInferInputの差異を確認 |
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