ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026)
API境界ではZodスキーマを唯一の契約にし、z.inferで型を導出、ExpressミドルウェアでリクエストをsafeParse、ハンドラ戻り値をレスポンススキーマで検証する。@asteasolutions/zod-to-openapiでOpenAPI 3.1を生成し、OpenAPI CIバリデーションとAPIエラーハンドリング設計と組み合わせると、型・ドキュメント・ランタイムの三者不一致をPR段階で止められる。
なぜAPI境界にランタイム検証が必要か
TypeScriptを採用したバックエンドでは、「DTOにinterfaceを書けば安全」と誤解されがちだ。しかし型は実行時に存在しない。express.json()がパースしたオブジェクトは、コンパイラの目にはCreateOrderBodyでも、実行時には余計なキー・欠落フィールド・文字列化された数値が混在したプレーンオブジェクトだ。
外部から入ってくるデータには次の3つの入口がある。
- HTTPリクエスト — クライアント・Webhook・モバイルアプリ
- 外部APIレスポンス — 決済ゲートウェイ・CRM・OAuthプロバイダ
- 設定・環境変数 — 起動時の
process.env(これも境界)
いずれも信頼できない入力として扱う。コンパイルが通っても、本番でundefined.idによる500や、SQLインジェクション前段の型崩れは防げない。
| コンパイル時のみ(interface/type) | Zodランタイム検証 |
|---|---|
| JSON.parse結果をそのまま信頼 | safeParseで構造・型・制約を検証 |
| 余計なキーを黙認(excess property未検知) | .strict()で未知キーを拒否可能 |
| 数値と数値文字列を区別できない | z.coerce.number()等で明示的変換 |
| OpenAPIと型定義が二重管理 | zod-to-openapiで単一ソース化 |
| エラー内容がバラバラ | ZodErrorをRFC 9457にマップ可能 |
ランタイム検証ライブラリの選択肢はZod、Valibot、ArkType、Ajv(JSON Schema)などがある。2026年時点のNode.js/Express/Fastifyエコシステムでは、TypeScript推論の自然さとOpenAPI生成ツールの成熟度からZodが最も採用例が多い。本記事はZodを中心に、API境界の設計パターンを体系的に述べる。
Zodの基本:スキーマ、parse、safeParse
Zodでは「スキーマ」がバリデーションルールと型情報の両方を担う。次の例は注文作成リクエストの最小スキーマだ。
import { z } from 'zod';
export const OrderItemSchema = z.object({
sku: z.string().min(1).max(64),
quantity: z.number().int().positive().max(999),
unitPriceCents: z.number().int().nonnegative(),
});
export const CreateOrderBodySchema = z.object({
customerId: z.string().uuid(),
items: z.array(OrderItemSchema).min(1).max(50),
note: z.string().max(500).optional(),
couponCode: z.string().regex(/^[A-Z0-9-]{3,32}$/).optional(),
});
export type CreateOrderBody = z.infer<typeof CreateOrderBodySchema>;
parseは失敗時にZodErrorをthrowする。HTTPハンドラでは例外フローが増えるため、境界ではsafeParseが推奨される。
const result = CreateOrderBodySchema.safeParse(req.body);
if (!result.success) {
// result.error は ZodError。issues 配列にフィールドパスとメッセージ
return res.status(400).json(formatZodProblem(result.error));
}
const body: CreateOrderBody = result.data;
safeParseの戻り値は判別共用体 { success: true; data: T } | { success: false; error: ZodError } なので、TypeScriptがresult.dataの存在をナローイングしてくれる。
parseとsafeParseの使い分け
| メソッド | 失敗時 | 向いている場面 |
|---|---|---|
.parse() | 例外 throw | 内部処理で既に検証済みの値、テスト、起動時設定 |
.safeParse() | { success: false } | HTTP境界、外部APIレスポンス、ユーザー入力 |
.parseAsync() | 非同期 refine | DB存在チェック付きカスタム検証 |
z.infer・z.input・z.outputの型推論
z.infer<typeof Schema>は、スキーマが出力する値の型を得る。デフォルトではz.outputと同義だ。
import { z } from 'zod';
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['createdAt', 'updatedAt']).default('createdAt'),
});
type QueryInput = z.input<typeof QuerySchema>;
// { page?: unknown; limit?: unknown; sort?: 'createdAt' | 'updatedAt' }
type QueryOutput = z.infer<typeof QuerySchema>;
// { page: number; limit: number; sort: 'createdAt' | 'updatedAt' }
Expressのreq.queryは文字列(または文字列配列)なので、ミドルウェア通過前はQueryInput、通過後はQueryOutputとして扱うのが正確だ。
実務では次のエイリアスをschemasファイルにまとめておくと読みやすい。
export const ListOrdersQuerySchema = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['pending', 'paid', 'shipped', 'cancelled']).optional(),
});
export type ListOrdersQuery = z.infer<typeof ListOrdersQuerySchema>;
export type ListOrdersQueryRaw = z.input<typeof ListOrdersQuerySchema>;
transformとrefine
transformは入力型と出力型を分離する。refine(およびsuperRefine)は複数フィールド横断の制約に使う。
const DateRangeSchema = z
.object({
from: z.coerce.date(),
to: z.coerce.date(),
})
.refine((data) => data.from <= data.to, {
message: 'fromはto以前である必要があります',
path: ['from'],
});
const NormalizedEmailSchema = z
.string()
.email()
.transform((email) => email.trim().toLowerCase());
refineの失敗は422(ビジネスルール)に回すか400(入力不正)に回すかはチームポリシー次第だ。APIエラーハンドリング設計では、Zodの構造エラーを400、ドメインサービス層の拒否を422と分けるパターンを詳述している。
リクエストスキーマ設計の実務パターン
API境界のリクエストはbody・query・params・headersの4箇所に分かれる。それぞれスキーマを分離し、ミドルウェアで一括検証する。
routes/orders.ts で HTTP メソッドとパスを定義する
schemas/orders.ts に Body / Query / Params / Headers の Zod スキーマを置く
middleware/validate.ts で safeParse し、成功時は req.validated に格納する
ハンドラは req.validated のみ参照し、req.body を直接触らない
ZodError を RFC 9457 の application/problem+json に変換して 400 を返す
zod-to-openapi で同じスキーマから OpenAPI requestBody / parameters を生成する
共有スキーマのディレクトリ構成
src/
schemas/
common.ts # Pagination, IdParam, ProblemDetails
orders.ts # OrderBody, OrderResponse
users.ts
middleware/
validate.ts
routes/
orders.ts
openapi/
registry.ts # zod-to-openapi 登録
generate.ts # openapi.yaml 出力
共通プリミティブ
import { z } from 'zod';
export const UuidParamSchema = z.object({
id: z.string().uuid(),
});
export const PaginationQuerySchema = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const IdempotencyKeyHeaderSchema = z.object({
'idempotency-key': z.string().uuid(),
});
export const BearerAuthHeaderSchema = z.object({
authorization: z
.string()
.regex(/^Bearer [A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/, {
message: 'Authorization ヘッダーは Bearer JWT 形式である必要があります',
}),
});
strict()とstrip():未知キーの扱い
公開APIでは未知フィールドを拒否する.strict()が安全側だ。内部マイクロサービス間で前方互換を優先する場合はデフォルトの.strip()(未知キー削除)も選べる。
export const StrictCreateUserSchema = z
.object({
email: z.string().email(),
displayName: z.string().min(1).max(80),
})
.strict();
Expressミドルウェア:型安全なvalidate
Express標準のRequest型にvalidatedを載せるには、モジュール拡張(declaration merging)を使う。
// types/express.d.ts
import type { z } from 'zod';
declare global {
namespace Express {
interface Request {
validated?: Record<string, unknown>;
}
}
}
export {};
// middleware/validate.ts
import type { Request, Response, NextFunction } from 'express';
import type { ZodTypeAny, ZodError } from 'zod';
type ValidationTargets = {
body?: ZodTypeAny;
query?: ZodTypeAny;
params?: ZodTypeAny;
headers?: ZodTypeAny;
};
function zodIssuesToFieldErrors(error: ZodError) {
return error.issues.map((issue) => ({
field: issue.path.join('.') || '(root)',
message: issue.message,
code: issue.code,
}));
}
export function formatZodProblem(error: ZodError, instance?: string) {
return {
type: 'https://api.example.com/errors/validation_failed',
title: 'Validation Failed',
status: 400,
detail: 'リクエストの形式が正しくありません。errors フィールドを確認してください。',
instance,
error_code: 'VALIDATION_FAILED',
errors: zodIssuesToFieldErrors(error),
};
}
export function validate(schemas: ValidationTargets) {
return (req: Request, res: Response, next: NextFunction) => {
req.validated = req.validated ?? {};
const requestId = req.header('x-request-id') ?? undefined;
for (const [target, schema] of Object.entries(schemas) as [
keyof ValidationTargets,
ZodTypeAny,
][]) {
if (!schema) continue;
const raw =
target === 'headers'
? Object.fromEntries(
Object.entries(req.headers).map(([k, v]) => [
k.toLowerCase(),
Array.isArray(v) ? v[0] : v,
]),
)
: req[target];
const result = schema.safeParse(raw);
if (!result.success) {
res
.status(400)
.type('application/problem+json')
.json(formatZodProblem(result.error, requestId));
return;
}
req.validated[target] = result.data;
}
next();
};
}
ルートへの適用例
// routes/orders.ts
import { Router } from 'express';
import {
CreateOrderBodySchema,
ListOrdersQuerySchema,
OrderResponseSchema,
} from '../schemas/orders';
import { UuidParamSchema } from '../schemas/common';
import { validate } from '../middleware/validate';
import { enforceResponse } from '../middleware/enforceResponse';
import { createOrder, getOrder, listOrders } from '../services/orders';
export const ordersRouter = Router();
ordersRouter.get(
'/',
validate({ query: ListOrdersQuerySchema }),
async (req, res, next) => {
try {
const query = req.validated!.query as ListOrdersQuery;
const result = await listOrders(query);
const payload = enforceResponse(OrderListResponseSchema, result);
res.status(200).json(payload);
} catch (err) {
next(err);
}
},
);
ordersRouter.post(
'/',
validate({ body: CreateOrderBodySchema }),
async (req, res, next) => {
try {
const body = req.validated!.body as CreateOrderBody;
const order = await createOrder(body);
const payload = enforceResponse(OrderResponseSchema, order);
res.status(201).json(payload);
} catch (err) {
next(err);
}
},
);
ordersRouter.get(
'/:id',
validate({ params: UuidParamSchema }),
async (req, res, next) => {
try {
const { id } = req.validated!.params as { id: string };
const order = await getOrder(id);
if (!order) {
res.status(404).type('application/problem+json').json({
type: 'https://api.example.com/errors/not_found',
title: 'Not Found',
status: 404,
detail: `注文 ${id} は存在しません`,
error_code: 'ORDER_NOT_FOUND',
});
return;
}
const payload = enforceResponse(OrderResponseSchema, order);
res.status(200).json(payload);
} catch (err) {
next(err);
}
},
);
ミドルウェア適用後もreq.bodyは生のまま残る。チーム規約でハンドラは req.validated のみ参照と決め、コードレビューでreq.body直参照を禁止すると、検証 bypass の事故を防げる。
レスポンススキーマ:契約違反をサーバー側で検出
クライアント入力だけでなく、サーバーが返すJSONも検証対象にすべきだ。理由は3つある。
- リファクタリングでフィールド名を変えたがOpenAPI更新を忘れた
nullとundefinedの混在がクライアントの厳密パースを壊す- 内部DTOから公開レスポンスへのマッピング漏れ
// middleware/enforceResponse.ts
import { z, type ZodTypeAny, type ZodError } from 'zod';
const isProduction = process.env.NODE_ENV === 'production';
const sampleRate = Number(process.env.RESPONSE_VALIDATE_SAMPLE_RATE ?? '1');
export function enforceResponse<T extends ZodTypeAny>(
schema: T,
data: unknown,
): z.infer<T> {
if (isProduction && Math.random() > sampleRate) {
return data as z.infer<T>;
}
const result = schema.safeParse(data);
if (!result.success) {
const message = `[response contract violation] ${formatIssues(result.error)}`;
if (isProduction) {
console.error(message);
return data as z.infer<T>;
}
throw new Error(message);
}
return result.data;
}
function formatIssues(error: ZodError): string {
return error.issues
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join('; ');
}
開発環境では即例外、本番ではログ + サンプリング——という段階的厳しさが運用しやすい。金融・決済ドメインでは本番も100% enforceするチームもある。
レスポンススキーマ定義例
import { z } from 'zod';
export const OrderResponseSchema = z.object({
id: z.string().uuid(),
customerId: z.string().uuid(),
status: z.enum(['pending', 'paid', 'shipped', 'cancelled']),
totalCents: z.number().int().nonnegative(),
items: z.array(
z.object({
sku: z.string(),
quantity: z.number().int().positive(),
lineTotalCents: z.number().int().nonnegative(),
}),
),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const OrderListResponseSchema = z.object({
data: z.array(OrderResponseSchema),
nextCursor: z.string().nullable(),
hasMore: z.boolean(),
});
export type OrderResponse = z.infer<typeof OrderResponseSchema>;
export type OrderListResponse = z.infer<typeof OrderListResponseSchema>;
日時はAPI境界ではISO 8601文字列に統一し、z.string().datetime()で縛る。DB内部はDateでも、JSON化前にtoISOString()する変換層を1箇所に集約する。
ZodErrorからRFC 9457へのマッピング
バリデーション失敗のレスポンス形式をAPIエラーハンドリング設計のProblem Detailsに揃えると、フロントエンドSDKがerror_codeとerrors[]でフォーム表示を自動化できる。
import type { ZodError } from 'zod';
export interface ProblemDetails {
type: string;
title: string;
status: number;
detail: string;
instance?: string;
error_code: string;
errors?: Array<{
field: string;
message: string;
code: string;
}>;
}
export function validationProblemFromZod(
error: ZodError,
options: { instance?: string; status?: number } = {},
): ProblemDetails {
return {
type: 'https://api.example.com/errors/validation_failed',
title: 'Validation Failed',
status: options.status ?? 400,
detail: '入力内容に誤りがあります。',
instance: options.instance,
error_code: 'VALIDATION_FAILED',
errors: error.issues.map((issue) => ({
field: issue.path.length > 0 ? issue.path.join('.') : '(root)',
message: issue.message,
code: issue.code,
})),
};
}
Zodのissue.codeはinvalid_type・too_small・invalid_stringなど機械可読なので、i18nレイヤーでユーザー向けメッセージに変換しやすい。
外部APIレスポンスの検証
自APIの入口だけでなく、Outboundの境界も同じZodスキーマで守る。決済プロバイダのWebhookやRESTレスポンスは、仕様変更・部分障害・プロキシ改ざんのリスクがある。
import { z } from 'zod';
const StripeCheckoutSessionSchema = z.object({
id: z.string(),
object: z.literal('checkout.session'),
payment_status: z.enum(['paid', 'unpaid', 'no_payment_required']),
amount_total: z.number().int().nullable(),
currency: z.string().length(3),
customer_email: z.string().email().nullable(),
});
type StripeCheckoutSession = z.infer<typeof StripeCheckoutSessionSchema>;
export async function fetchCheckoutSession(
sessionId: string,
apiKey: string,
): Promise<StripeCheckoutSession> {
const response = await fetch(
`https://api.stripe.com/v1/checkout/sessions/${sessionId}`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
);
if (!response.ok) {
throw new Error(`Stripe API error: ${response.status}`);
}
const json: unknown = await response.json();
const parsed = StripeCheckoutSessionSchema.safeParse(json);
if (!parsed.success) {
throw new Error(
`Stripe response schema mismatch: ${parsed.error.message}`,
);
}
return parsed.data;
}
Outbound検証に失敗した場合はリトライしても直らないことが多い。アラートを上げ、フォールバックせず処理を止める設計が安全だ。
zod-to-openapi:ZodからOpenAPI 3.1を生成(2026)
2026年時点では@asteasolutions/zod-to-openapi(v7系)がOpenAPI 3.1生成のデファクトに近い。Zodスキーマに.openapi()メタデータを付け、レジストリにルートを登録し、YAML/JSONを出力する。
// openapi/registry.ts
import { extendZodWithOpenApi, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
import {
CreateOrderBodySchema,
OrderListResponseSchema,
OrderResponseSchema,
ListOrdersQuerySchema,
} from '../schemas/orders';
extendZodWithOpenApi(z);
export const registry = new OpenAPIRegistry();
export const CreateOrderBodyOpenApi = CreateOrderBodySchema.openapi('CreateOrderBody');
export const OrderResponseOpenApi = OrderResponseSchema.openapi('OrderResponse');
export const OrderListResponseOpenApi = OrderListResponseSchema.openapi('OrderListResponse');
registry.register('CreateOrderBody', CreateOrderBodyOpenApi);
registry.register('OrderResponse', OrderResponseOpenApi);
registry.register('OrderListResponse', OrderListResponseOpenApi);
registry.registerPath({
method: 'post',
path: '/orders',
tags: ['orders'],
operationId: 'createOrder',
summary: '注文を作成',
request: {
body: {
content: {
'application/json': {
schema: CreateOrderBodyOpenApi,
},
},
},
},
responses: {
201: {
description: '作成成功',
content: {
'application/json': {
schema: OrderResponseOpenApi,
},
},
},
400: {
description: 'バリデーション失敗',
content: {
'application/problem+json': {
schema: z.object({
type: z.string().url(),
title: z.string(),
status: z.number(),
detail: z.string(),
error_code: z.string(),
}),
},
},
},
},
});
registry.registerPath({
method: 'get',
path: '/orders',
tags: ['orders'],
operationId: 'listOrders',
summary: '注文一覧',
request: {
query: ListOrdersQuerySchema,
},
responses: {
200: {
description: '成功',
content: {
'application/json': {
schema: OrderListResponseOpenApi,
},
},
},
},
});
export function generateOpenApiDocument() {
const generator = new OpenApiGeneratorV31(registry.definitions);
return generator.generateDocument({
openapi: '3.1.0',
info: {
title: 'Orders API',
version: '2.0.0',
description: 'Zodスキーマから生成されたOpenAPI。手編集禁止。',
},
servers: [{ url: 'https://api.example.com/v2' }],
});
}
// scripts/generate-openapi.ts
import { writeFileSync } from 'node:fs';
import { stringify } from 'yaml';
import { generateOpenApiDocument } from '../src/openapi/registry';
const doc = generateOpenApiDocument();
writeFileSync('openapi/openapi.yaml', stringify(doc), 'utf8');
console.log('Wrote openapi/openapi.yaml');
生成したopenapi/openapi.yamlをOpenAPI CIバリデーションのパイプラインに渡す。Zod → OpenAPI → Spectral Lint → openapi-diffの流れで、実装とドキュメントの乖離をPR段階で検出できる。
openapi.yamlを手で直すと次回生成で上書きされる。examples・externalDocs・サーバーURLなどOpenAPI固有のメタデータは、registry.registerPathのoptionsか、生成後フックでマージする。Single Source of Truthは常にZod側に置く。
環境変数と起動時検証
APIサーバーの起動時も境界だ。process.envはすべて文字列(またはundefined)なので、Zodでパースしてから設定オブジェクトに載せる。
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_ISSUER: z.string().url(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export type Env = z.infer<typeof EnvSchema>;
export function loadEnv(): Env {
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment variables:');
for (const issue of result.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
}
process.exit(1);
}
return result.data;
}
起動失敗はfail fast。本番でDB URL typoに気づくのはデプロイ直後が理想で、リクエスト処理中ではない。
テスト戦略:スキーマ単体とHTTP統合
Zodスキーマは純関数に近いので、ユニットテストが書きやすい。
import { describe, it, expect } from 'vitest';
import { CreateOrderBodySchema } from '../schemas/orders';
describe('CreateOrderBodySchema', () => {
it('accepts valid payload', () => {
const input = {
customerId: '550e8400-e29b-41d4-a716-446655440000',
items: [{ sku: 'SKU-1', quantity: 2, unitPriceCents: 1500 }],
};
const result = CreateOrderBodySchema.safeParse(input);
expect(result.success).toBe(true);
});
it('rejects empty items array', () => {
const input = {
customerId: '550e8400-e29b-41d4-a716-446655440000',
items: [],
};
const result = CreateOrderBodySchema.safeParse(input);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toContain('items');
}
});
it('rejects invalid uuid', () => {
const input = {
customerId: 'not-a-uuid',
items: [{ sku: 'X', quantity: 1, unitPriceCents: 100 }],
};
const result = CreateOrderBodySchema.safeParse(input);
expect(result.success).toBe(false);
});
});
HTTP統合テスト(supertest等)では、実際に400 Problem Detailsが返ること、OpenAPI生成物のexampleと一致することを確認する。CIではgenerate-openapi → git diff --exit-code openapi/でコミット漏れも検出できる。
Fastify・Honoとの統合(参考)
Express以外でもパターンは同じだ。Fastifyはfastify-type-provider-zod、Honoは@hono/zod-validatorがZodスキーマをルート定義に直結できる。いずれもスキーマファイルをframework非依存で置くのが、将来のフレームワーク移行コストを下げる。
| フレームワーク | 統合方法 |
|---|---|
| Express | 自前validateミドルウェア(本記事) |
| Fastify | fastify-type-provider-zod |
| Hono | @hono/zod-validator |
| tRPC | 入力はprocedure定義に内包(REST OpenAPIとは別系統) |
REST公開APIとOpenAPIドキュメントが必要なら、Express/Fastify + zod-to-openapiが2026年も堅実な選択肢だ。
パフォーマンスとキャッシュ
Zodのオーバーヘッドは多くのCRUD APIでは無視できるが、高QPSエンドポイントでは次を検討する。
- スキーマの事前コンパイル — 同一スキーマを毎リクエストnewしない(モジュールスコープで定義)
- AJVへのコンパイル —
zod-to-json-schemaでJSON Schemaを出し、起動時にAjv compile(極端なホットパス向け) - レスポンス検証のサンプリング — 本記事の
enforceResponseパターン
マイクロベンチマークでは、単純オブジェクトのsafeParseは数十マイクロ秒程度。DBクエリや外部API呼び出しに比べれば小さい。 premature optimization より、契約違反の早期検出を優先する判断が妥当だ。
セキュリティ:バリデーションは防御の一部
Zodは入力の形を整えるが、XSS・SQLi・認可バイパスまでは防がない。役割分担を明確にする。
| 層 | 責務 |
|---|---|
| Zod(境界) | 型・長さ・形式・必須・列挙値 |
| 認可 | ユーザーがリソースを操作できるか |
| ORM/パラメータ化 | SQLインジェクション防止 |
| 出力エスケープ | XSS(HTMLコンテキスト) |
| レート制限 | 乱用・総当たり |
z.string()にHTMLを入れても通る。リッチテキストを許すAPIではサニタイズライブラリを別途適用する。
よくある設計ミス
| アンチパターン | 推奨 |
|---|---|
| interfaceとZod定義が別ファイルで二重管理 | Zodのみ定義し z.infer で型を導出 |
| バリデーション後も req.body を参照 | req.validated のみ使う規約 |
| ZodError.message をそのまま500で返す | 400 + RFC 9457 + error_code |
| OpenAPIを手書きでメンテ | zod-to-openapi生成 + CI Lint |
| coerceのみで上限なし(巨大数値) | .int().max() で範囲制限 |
| レスポンス検証なし | enforceResponse(少なくとも開発環境) |
チェックリスト:API境界Zod導入の完了条件
導入が「動いている」だけでなく「運用できる」状態か、次で確認する。
- すべての公開エンドポイントにリクエストZodスキーマが付いている
- 型は
z.inferから導出し、interfaceの重複定義がない - バリデーション失敗は400 +
application/problem+json+VALIDATION_FAILED - ビジネスルール拒否は422で、Zod層とドメイン層の責務が分離されている
- レスポンススキーマがあり、開発環境で契約違反が即検知される
-
openapi/openapi.yamlがZodから生成され、CIでSpectral + diffが通る - 環境変数は起動時Zodパースでfail fast
- 外部APIレスポンスにOutbound Zod検証がある
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| APIキャッシュ設計ガイド | APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務 |
| Web APIのべき等性(Idempotency)設計 | Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド |
| API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 | API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 |
| APIのレート制限(Rate Limiting)実装ガイド | APIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング |
| APIレート制限と429対応 | APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装 |
| WebHook設計のベストプラクティス | WebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する |
| GraphQL N+1 問題の解決 | GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
| Event SourcingとCQRSの基礎 | Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド |
| Redisキャッシュスタンピード防止ガイド | Redisキャッシュスタンピード防止ガイド|Singleflight・Mutex・SET NX・確率的早期失効 |
| PostgreSQL接続プール枯渇の対処法 | PostgreSQL接続プール枯渇の対処法|too many connections・PgBouncer・Node.js pg |
まとめ
TypeScriptの型は開発者への約束であり、実行時の盾ではない。API境界——リクエスト受信、レスポンス送信、外部API取得、環境変数読み込み——ではZodでスキーマを定義し、safeParseで失敗を制御フローに組み込む。
z.inferで型を1本化し、Expressミドルウェアでreq.validatedに載せ、レスポンスはenforceResponseで契約を守る。@asteasolutions/zod-to-openapiでOpenAPI 3.1を生成し、OpenAPI spec validation CI pipelineでPRごとにLintとbreaking change検出を回す。エラー形式はWeb APIのエラーハンドリング設計のRFC 9457に揃えれば、フロント・バック・ドキュメント・SDKが同じ「契約言語」で動く。
2026年のAPI開発では、「型があるから安全」ではなく「スキーマが単一ソースで、ランタイムとCIの両方で検証されているから安全」という基準に更新するのが、保守コストと障害率のバランスに優れる。