フォームバリデーションだけじゃないぞ!Zodで始めるJSONスキーマ検証

こんにちは!SINIS for Instagram開発チームに所属しているヒロ氏と申します。 Zodといえば、「React バリデーション」で検索をすると、必ずといっていいほど検索上位に上がってくるライブラリです。 そのため、Zodはフォームバリデーションのためのライブラリとして認知されており、既に活用されている方も多いと思います。 しかしZodにはフォームバリデーション以外の活用方法もあります。 今回はその中の一つであるJSONのスキーマ検証について、 TypeScriptでJSON.parse()を扱う際の課題、ユースケースとあわせてお話します。

本記事は Zod v4(4.x系)を前提としています。

Zodについて

Zodは、TypeScriptファーストのバリデーションライブラリです。 https://zod.dev/

https://github.com/colinhacks/zod

import { z } from "zod";

// スキーマを定義(カスタムメッセージ付き)
const UserSchema = z.object({
  name: z.string({ error: "ユーザー名はテキストで入力してください" }),
  email: z.email({ error: "メールアドレスは正しい形式で入力してください" }),
});

try {
  UserSchema.parse({ name: 42, email: "不正なメールアドレス" });
} catch (error) {
  if (error instanceof z.ZodError) {
    error.issues;
    /* [
      {
        expected: 'string',
        code: 'invalid_type',
        path: [ 'name' ],
        message: 'ユーザー名はテキストで入力してください'
      },
      {
        origin: 'email',
        code: 'invalid_format',
        path: [ 'email' ],
        message: 'メールアドレスは正しい形式で入力してください'
      }
    ] */
  }
}

スキーマを定義すれば、定義をそのまま利用しデータの解析・エラーハンドリングをおこなうことができます。

JSON.parse()の問題点

外部から受け取ったJSON文字列をパースする場面はよくあるケースかと思います。 しかしJSON.parse()の戻り値はany型であり、TypeScriptの型安全性を十分に活かすことができない問題があります。

const response = '{"id": 1, "name": "田中太郎", "email": "tanaka@example.com"}';
const data = JSON.parse(response);
console.log(typeof data); // any

ユーザー定義型と型ガードでのつらいポイント

any型をそのまま用いるのではなく、型を定義し型ガードを実装するという手もあります。

// 型定義
interface User {
  id: number;
  name: string;
  email: string;
}

// 型ガード関数
function isUser(value: unknown): value is User {
  if (typeof value !== "object" || value === null) {
    return false;
  }

  const obj = value as Record<string, unknown>;

  return (
    typeof obj.id === "number" &&
    typeof obj.name === "string" &&
    typeof obj.email === "string"
  );
}

// 使用例
const response = '{"id": 1, "name": "田中太郎", "email": "tanaka@example.com"}';
const data: unknown = JSON.parse(response);
if (isUser(data)) {
  console.log(data.name); // User型として推論される
}

この実装でも機能はしますが、いくつかの問題があります。

1. 型定義と型ガードの二重管理

Userインターフェースを変更したら、isUser関数も同時に修正が必要です。片方だけ更新して、もう片方を忘れるという不整合が起きやすい構造です。

2. ネストが深いオブジェクトの対応が大変になる

先ほどのコード例はJSONのネストが浅くプロパティも少ないため、型ガードの実装が比較的容易でした。 しかし下記のように配列・ネストしたオブジェクト・リテラル型が組み合わさったJSONになると、型ガードの実装は一気に複雑になります。一つひとつのプロパティに対して再帰的に型チェックを書く必要があり、メンテナンスコストも跳ね上がります。

interface Order {
  orderId: string;
  customer: {
    id: number;
    name: string;
    email: string;
  };
  items: Array<{
    productId: number;
    name: string;
    quantity: number;
    price: number;
    variant: {
      color: string;
      size: "S" | "M" | "L" | "XL";
    };
  }>;
  shipping: {
    address: {
      postalCode: string;
      prefecture: string;
      city: string;
      street: string;
    };
    method: "standard" | "express";
  };
  status: "pending" | "paid" | "shipped" | "delivered";
}

もう一例挙げると、LLMのAPIレスポンスはスキーマが複雑なことが多く、特に構造化出力を扱う場合、自前で型ガードを実装するとメンテナンスが大変になります。 https://ai.google.dev/gemini-api/docs/structured-output?hl=ja&example=recipe

3. バリデーションロジックが不十分

先ほどの型ガードではemailが文字列であることしか検証していません。「メールアドレスとして有効な形式か?」までは見ていません。 本格的なバリデーションを追加しようとすると、さらにコードが膨れ上がります。

Zodを使ってJSONの検証をしてみる

ここでZodを使ってJSONの検証をしてみましょう。 以下は基本的な使い方とネストが深いJSONの検証のコード例です。

基本的な使い方

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.email(),
});

type User = z.infer<typeof UserSchema>;

// parseを通すことで、dataはUser型として推論される
const response = '{"id": 1, "name": "テスト太郎", "email": "test@example.com"}';
const data = UserSchema.parse(JSON.parse(response));

このようにスキーマを定義するだけで、型定義・型ガード・バリデーションがすべて揃います。

ネストが深いJSONの検証

const VariantSchema = z.object({
  color: z.string(),
  size: z.enum(["S", "M", "L", "XL"]),
});

const OrderItemSchema = z.object({
  productId: z.int(),
  name: z.string(),
  quantity: z.int().positive(),
  price: z.int().nonnegative(),
  variant: VariantSchema,
});

const AddressSchema = z.object({
  postalCode: z.string().regex(/^\d{3}-\d{4}$/, "郵便番号は7桁のハイフン区切りで入力してください"),
  prefecture: z.string(),
  city: z.string(),
  street: z.string(),
});

const OrderSchema = z.object({
  orderId: z.string(),
  customer: z.object({
    id: z.number(),
    name: z.string(),
    email: z.email(),
  }),
  items: z.array(OrderItemSchema),
  shipping: z.object({
    address: AddressSchema,
    method: z.enum(["standard", "express"]),
  }),
  status: z.enum(["pending", "paid", "shipped", "delivered"]),
});

type Order = z.infer<typeof OrderSchema>;

型定義とバリデーションロジックが一箇所にまとまっているため、変更があっても該当箇所を直すだけで対応できます。 手書きの型ガードでここまで書こうとすると、コードが数倍に膨れ上がります。

エラーハンドリングも充実

parse()は検証失敗時に例外をスローしますが、safeParse()を使えば例外なしで結果を受け取れます。

const response = '{"orderId": "001", "customer": {...}, "items": [...], ...}';
const result = OrderSchema.safeParse(JSON.parse(response));

if (result.success) {
  // result.data は Order 型
  console.log(result.data.customer.name);
} else {
  // result.error から検証エラーの詳細を取得できる
}

result.error には検証失敗の情報が含まれており、用途に応じて3つの取り出し方が用意されています。

z.prettifyError()

ログ出力やデバッグなど、人間が目視で確認する用途に向いています。

if (!result.success) {
  console.error(z.prettifyError(result.error));
}

出力例:

✖ Invalid input: expected string, received number
  → at customer.name
✖ Invalid input: expected number, received string
  → at items[0].quantity

エラー箇所がパス付きで一覧表示されるため、デバッグ時に直感的に問題を把握できます。

z.treeifyError()

JSONのネスト構造をそのまま反映したオブジェクトで取得できます。フロントエンドでフィールドごとにエラー表示を出したい場合などに便利です。

if (!result.success) {
  const tree = z.treeifyError(result.error);
  console.error(tree);
}

出力例:

{
  errors: [],
  properties: {
    customer: {
      errors: [],
      properties: {
        name: { errors: ["Invalid input: expected string, received number"] }
      }
    },
    items: {
      errors: [],
      items: [
        {
          errors: [],
          properties: {
            quantity: { errors: ["Invalid input: expected number, received string"] }
          }
        }
      ]
    }
  }
}

例えば tree.properties.customer.properties.name.errors のようにアクセスすれば、特定フィールドのエラーだけを取り出せます。

error.issues

エラーを独自のロジックで処理したい場合は、issues で生の配列にアクセスできます。

if (!result.success) {
  for (const issue of result.error.issues) {
    console.log({
      code: issue.code,     // 例: 'invalid_type'
      path: issue.path,     // 例: ['customer', 'name']
      message: issue.message,
    });
  }
}

issue には code(エラー種別)、path(パス配列)、message(メッセージ)などが含まれており、エラー種別ごとに異なる処理を分岐させたい場合や、独自のエラーレポートを組み立てたい場合に有用です。


エラー内容が構造化されているので、どのフィールドがどう間違っていたかを簡単に特定できます。 自社APIはともかく外部APIは事前告知なしに仕様・レスポンスの変更が発生することもあります。 Zodで検証することで、エラーの詳細を早期に把握しプロダクトの品質を向上させることができます。

注意点

とはいえZod導入にも注意点はあります。 軽量で構造が単純なJSONを扱う場合、Zodの導入はオーバーエンジニアリングになる可能性があります。導入前に、型ガードで十分かどうか、Zodを使うメリットが上回るかどうかを検討することをおすすめします。

まとめ

  • JSON.parse()の戻り値はany型であり、TypeScriptの型安全性が失われる
  • 手書きの型ガードは二重管理・バリデーション不足という問題を抱える
  • Zodを使えば、スキーマ定義から型・型ガード・バリデーションがすべて導出される
  • ただし軽量なJSONに対してZodを導入するのはオーバーエンジニアリングに繋がる可能性があるため、導入前に本当に適しているかは検討する必要がある

テテマーチでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! herp.careers

エンジニアチームガイドはこちら! https://tetemarche01.notion.site/30bdd1fbde384fcfbb641775956ba4c2tetemarche01.notion.site