【Node.js実戦習得】第11回:Zod による型安全なリクエストバリデーションと共通ミドルウェア構築

社員ブログ

1. なぜ手作業のバリデーションは破綻するのか?

酒本先輩!

第10回までで Express のエラーハンドリングとロギングミドルウェアが完成して、開発がかなり快適になりました!

でも、クライアントから送られてくる req.bodyreq.query のデータチェック(バリデーション)を手動の if 文で書いていると、コードが怪しい臭いを放ち始めていて…。

そうなのよ!

手動のバリデーションには、実務で大きな3つの課題が存在するわ。

// ❌ ダメな例:手作業による if 文チェックの乱立
app.post('/api/cart/items', (req, res) => {
  const { productName, price, quantity } = req.body;

  if (!productName || typeof productName !== 'string') {
    return res.status(400).json({ error: '商品名は必須文字列です' });
  }
  if (typeof price !== 'number' || price <= 0) {
    return res.status(400).json({ error: '価格は正の数である必要があります' });
  }
  // ...項目が増えるたびに if 文が無限増殖し、TypeScript の型も any に近い状態に!
});
  • コードの肥大化: 本来集中すべきビジネスロジックの前に、大量の if 文が立ちはだかる。
  • TypeScript の型不一致: if 文でチェックしても、req.body の型は any または unknown のままで、型安全性が保証されない。
  • クエリー文字列の型変換漏れ: req.queryreq.params は常に「文字列(string)」として届くため、"5" を数値の 5 に変換するバリデーションを毎回書くのはバグの温床になる。

これを一発で解決するのが、現在 TypeScript エコシステムで圧倒的人気を誇るスキーマ検証ライブラリ Zod よ!

2. Zod の基本原理と型自動抽出(z.output

Zod の最大の強みは、スキーマ(バリデーションルール)を1つ定義するだけで、そこから TypeScript の型を自動抽出できる点にあるわ。

パッケージのインストール

npm install zod

スキーマ定義と型抽出のコード

※ Zod v4 や TypeScript のモジュール解決環境で型エラー(has no exported member 'infer')を回避するため、型抽出には z.output(または z.TypeOf)を使用します。

import { z } from 'zod';

// 1. バリデーションスキーマの定義
export const createCartItemSchema = z.object({
  productName: z.string({ required_error: '商品名は必須です。' }).min(1, '商品名は1文字以上で指定してください。'),
  price: z.number({ required_error: '価格は必須です。' }).positive('価格は正の数である必要があります。'),
  quantity: z.number().int().positive().optional().default(1),
});

// 2. スキーマから TypeScript の型を自動抽出(z.output を使用)
export type CreateCartItemInput = z.output<typeof createCartItemSchema>;

3. クエリー文字列の型変換(z.coerce

Express の req.queryreq.params で受け取る値は、?limit=5 のようにすべて文字列型 (string) になるの。

これを自動的に数値型 (number) に変換・検証するのが z.coerce(強制変換)よ!

// クエリーパラメータ用のスキーマ定義
export const getCartQuerySchema = z.object({
  // "5" という文字列を自動的に数値 5 に変換し、1以上の整数か検証する
  limit: z.coerce.number().int().positive().optional().default(10),
  search: z.string().optional(),
});

4. 実務で必須!Zod の高度なテクニック(.transform().refine()

Zod は単なる型チェックだけじゃないの。

実務で極めて頻出する『データの自動整形』や『複雑な条件の相関チェック』も強力にサポートしてくれるわ!

.transform() によるデータの変換・整形・サニタイズ

入力値を判定するだけでなく、そのまま安全な値へ整形して後続の処理に渡せるのよ。

const couponSchema = z.object({
  // 大文字小文字の揺れを自動で大文字統一&不要な前後の空白を除去
  code: z.string().trim().toUpperCase(),
  // "100" などの文字列型数値を自動で number 型に変換してから範囲検証する
  discountAmount: z.string().transform((val) => Number(val)).pipe(
    z.number().positive('割引額は数値である必要があります。')
  ),
});

.refine() による複数フィールドを跨ぐ相関チェック

『割引適用時の上限額チェック』や『開始日と終了日の前後関係』など、単一のフィールドだけでは完結しないビジネスロジックの検証には .refine() を使うの!

const discountRuleSchema = z.object({
  discountRate: z.number().min(0).max(1), // 0.0〜1.0 (例: 0.2 = 20%引き)
  maxDiscountAmount: z.number().positive(),
}).refine((data) => {
  // 割引率が 50% (0.5) を超える場合は、必ず上限額が 10,000 円以下でなければならないルール
  if (data.discountRate > 0.5 && data.maxDiscountAmount > 10000) {
    return false;
  }
  return true;
}, {
  message: '50%を超える高還元率の場合、最大割引額は 10,000 円以下に制限されます。',
  path: ['maxDiscountAmount'], // エラーを表示したい対象フィールドを指定できる!
});

5. 実践:Express 仕様に完全準拠した共通バリデーションミドルウェア

Express において、req.query はゲッター(読み取り専用プロパティ)として定義されているため、req.query = parsedData のように直接代入すると TypeError: Cannot set property query... ランタイムエラーが発生してしまうわ。

 

プロパティを安全に上書き塗り替えする共通ミドルウェアを作りましょう。

共通バリデーションミドルウェア (src/middlewares/validate.middleware.ts)

// src/middlewares/validate.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';

interface RequestValidationSchemas {
  body?: ZodSchema;
  query?: ZodSchema;
  params?: ZodSchema;
}

export const validateRequest = (schemas: RequestValidationSchemas) => {
  return (req: Request, res: Response, next: NextFunction): void => {
    try {
      // 1. req.body の検証と代入(.transform() や .refine() の結果も適用される)
      if (schemas.body) {
        req.body = schemas.body.parse(req.body);
      }

      // 2. req.query の検証(ゲッター回避のため Object.assign でプロパティ塗り替え)
      if (schemas.query) {
        const parsedQuery = schemas.query.parse(req.query);
        for (const key in req.query) {
          delete (req.query as any)[key];
        }
        Object.assign(req.query as any, parsedQuery);
      }

      // 3. req.params の検証と塗り替え
      if (schemas.params) {
        const parsedParams = schemas.params.parse(req.params);
        Object.assign(req.params as any, parsedParams);
      }

      next();
    } catch (error) {
      if (error instanceof ZodError) {
        // Zodのエラー構造をフロントエンドが扱いやすいフォーマットに整形
        const formattedErrors = error.issues.map((issue) => ({
          field: issue.path.join('.'),
          message: issue.message,
          rule: issue.code,
        }));

        res.status(400).json({
          error: 'VALIDATION_ERROR',
          message: 'リクエストデータに不備があります。',
          details: formattedErrors,
        });
        return;
      }
      next(error);
    }
  };
};

6. 組み込みと完全コード例 (src/index.ts)

.transform().refine() を含めたすべての要素をアプリケーションに組み込んでみましょう!

// src/index.ts
import express, { Request, Response } from 'express';
import { z } from 'zod';
import { validateRequest } from './middlewares/validate.middleware';

const app = express();
app.use(express.json());

// カートアイテムのデータ型
interface CartItem {
  id: number;
  productName: string;
  price: number;
  quantity: number;
  couponCode?: string;
}

let cartItems: CartItem[] = [
  { id: 1, productName: 'エルゴノミクス マウス', price: 8800, quantity: 1 },
  { id: 2, productName: 'メカニカルキーボード', price: 15400, quantity: 2 },
];
let nextId = 3;

// --- Zod スキーマ定義 ---

// 1. アイテム追加用スキーマ (.transform() と .refine() を活用)
const createCartItemSchema = z.object({
  productName: z.string().min(1, '商品名は必須です。'),
  price: z.number().positive('価格は正の数である必要があります。'),
  quantity: z.number().int().positive().optional().default(1),
  // クーポンコードのサニタイズ(トリム&大文字化)
  couponCode: z.string().trim().toUpperCase().optional(),
  discountRate: z.number().min(0).max(1).optional().default(0),
  maxDiscountAmount: z.number().positive().optional().default(5000),
}).refine((data) => {
  // 割引率が 50% (0.5) を超える場合、最大割引額は 10,000 円以下でなければならない相関チェック
  if (data.discountRate > 0.5 && data.maxDiscountAmount > 10000) {
    return false;
  }
  return true;
}, {
  message: '50%を超える高還元率の場合、最大割引額は 10,000 円以下に制限されます。',
  path: ['maxDiscountAmount'],
});

// 2. クエリーパラメータ用スキーマ (z.coerce による数値自動変換)
const getCartQuerySchema = z.object({
  limit: z.coerce.number().int().positive().optional().default(10),
  search: z.string().optional(),
});

// 3. パスパラメータ用スキーマ
const cartItemIdParamSchema = z.object({
  id: z.coerce.number().int().positive('IDは1以上の正の整数である必要があります。'),
});


// --- API エンドポイント ---

// GET: 一覧取得
app.get(
  '/api/cart/items',
  validateRequest({ query: getCartQuerySchema }),
  (req: Request, res: Response) => {
    const { limit, search } = req.query as unknown as { limit: number; search?: string };

    let result = cartItems;
    if (search) {
      result = result.filter((item) => item.productName.includes(search));
    }

    res.status(200).json({
      data: result.slice(0, limit),
      total: result.length,
      limit,
    });
  }
);

// POST: カートアイテム追加(Zod で自動整形&相関チェックされたデータが届く)
app.post(
  '/api/cart/items',
  validateRequest({ body: createCartItemSchema }),
  (req: Request, res: Response) => {
    const { productName, price, quantity, couponCode } = req.body;

    const newItem: CartItem = {
      id: nextId++,
      productName,
      price,
      quantity,
      // 小文字 "summer2026" で送られても、ミドルウェア通過後は "SUMMER2026" に変換されている!
      couponCode,
    };

    cartItems.push(newItem);
    res.status(201).json(newItem);
  }
);

// DELETE: アイテム削除
app.delete(
  '/api/cart/items/:id',
  validateRequest({ params: cartItemIdParamSchema }),
  (req: Request, res: Response) => {
    const { id } = req.params as unknown as { id: number };

    const index = cartItems.findIndex((item) => item.id === id);
    if (index === -1) {
      res.status(404).json({ error: 'NOT_FOUND', message: '指定されたアイテムが存在しません。' });
      return;
    }

    cartItems.splice(index, 1);
    res.status(204).send();
  }
);

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

7. curl による詳細動作確認

.transform() によるデータの自動変換確認

小文字の "summer2026 "(末尾に空白あり)を送ると、Zod によって自動的にトリム&大文字化されて保存されます。

curl -i -X POST http://localhost:3000/api/cart/items \
  -H "Content-Type: application/json" \
  -d '{"productName": "ゲーミングモニター", "price": 42000, "couponCode": "summer2026 "}'

レスポンス(201 Created):

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{
  "id": 3,
  "productName": "ゲーミングモニター",
  "price": 42000,
  "quantity": 1,
  "couponCode": "SUMMER2026"
}

.refine() による相関エラー(相関チェック失敗)の検証

discountRate: 0.8(80%引き)かつ maxDiscountAmount: 20000(上限2万円)を送信して相関ルールを破ってみます。

curl -i -X POST http://localhost:3000/api/cart/items \
  -H "Content-Type: application/json" \
  -d '{"productName": "高級オフィスチェア", "price": 120000, "discountRate": 0.8, "maxDiscountAmount": 20000}'

レスポンス(400 Bad Request):

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{
  "error": "VALIDATION_ERROR",
  "message": "リクエストデータに不備があります。",
  "details": [
    {
      "field": "maxDiscountAmount",
      "message": "50%を超える高還元率の場合、最大割引額は 10,000 円以下に制限されます。",
      "rule": "custom"
    }
  ]
}

本日のまとめ

  • Zod と TypeScript 型抽出: z.output<typeof schema> で型を自動作成し、型とバリデーションルールの二重管理を排除する。
  • データ整形 .transform() リクエストデータのサニタイズ(大文字化・空白除去・型キャスト)をバリデーション層で完了させる。
  • 相関チェック .refine() 複数フィールドにまたがる複雑なビジネスルールを綺麗にカプセル化する。
  • Express 仕様の考慮: req.query はゲッターであるため直接代入を行わず、Object.assign() で安全に内部プロパティを更新する。

次回:第12回「Layered Architecture(レイヤードアーキテクチャ)による関心の分離とフォルダ設計」へ続く

タイトルとURLをコピーしました