ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026)

Zod TypeScript API OpenAPI Express バリデーション ランタイム
結論

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つの入口がある。

  1. HTTPリクエスト — クライアント・Webhook・モバイルアプリ
  2. 外部APIレスポンス — 決済ゲートウェイ・CRM・OAuthプロバイダ
  3. 設定・環境変数 — 起動時の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は失敗時にZodErrorthrowする。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()非同期 refineDB存在チェック付きカスタム検証

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箇所に分かれる。それぞれスキーマを分離し、ミドルウェアで一括検証する。

1

routes/orders.ts で HTTP メソッドとパスを定義する

2

schemas/orders.ts に Body / Query / Params / Headers の Zod スキーマを置く

3

middleware/validate.ts で safeParse し、成功時は req.validated に格納する

4

ハンドラは req.validated のみ参照し、req.body を直接触らない

5

ZodError を RFC 9457 の application/problem+json に変換して 400 を返す

6

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.bodyは生のまま残る。チーム規約でハンドラは req.validated のみ参照と決め、コードレビューでreq.body直参照を禁止すると、検証 bypass の事故を防げる。

レスポンススキーマ:契約違反をサーバー側で検出

クライアント入力だけでなく、サーバーが返すJSONも検証対象にすべきだ。理由は3つある。

  1. リファクタリングでフィールド名を変えたがOpenAPI更新を忘れた
  2. nullundefinedの混在がクライアントの厳密パースを壊す
  3. 内部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_codeerrors[]でフォーム表示を自動化できる。

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.codeinvalid_typetoo_smallinvalid_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.yamlOpenAPI 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-openapigit diff --exit-code openapi/コミット漏れも検出できる。

Fastify・Honoとの統合(参考)

Express以外でもパターンは同じだ。Fastifyはfastify-type-provider-zod、Honoは@hono/zod-validatorがZodスキーマをルート定義に直結できる。いずれもスキーマファイルをframework非依存で置くのが、将来のフレームワーク移行コストを下げる。

フレームワーク統合方法
Express自前validateミドルウェア(本記事)
Fastifyfastify-type-provider-zod
Hono@hono/zod-validator
tRPC入力はprocedure定義に内包(REST OpenAPIとは別系統)

REST公開APIとOpenAPIドキュメントが必要なら、Express/Fastify + zod-to-openapiが2026年も堅実な選択肢だ。

パフォーマンスとキャッシュ

Zodのオーバーヘッドは多くのCRUD APIでは無視できるが、高QPSエンドポイントでは次を検討する。

  1. スキーマの事前コンパイル — 同一スキーマを毎リクエストnewしない(モジュールスコープで定義)
  2. AJVへのコンパイルzod-to-json-schemaでJSON Schemaを出し、起動時にAjv compile(極端なホットパス向け)
  3. レスポンス検証のサンプリング — 本記事の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の両方で検証されているから安全」という基準に更新するのが、保守コストと障害率のバランスに優れる。