レビュー済み·難易度: 中級·更新: 2026-04-18

Zod の discriminatedUnion でバリアント型を型安全に処理する

z.discriminatedUnion を使い、status フィールドで分岐するバリアント型(成功 / エラー / ローディング等)を型安全にパース・絞り込む例。

nextjsvalidationzod

対応バージョン

nextjs 15react 19zod 3

前提環境

Zod の z.object と z.union の基本を理解していること

概要

z.discriminatedUnion を使い、status フィールドで分岐するバリアント型を型安全にパース・絞り込む。z.union との違いは、discriminator フィールドで先に型を特定するため TypeScript の型推論が正確になり、エラーメッセージも分かりやすい点。

インストール

npm install zod

実装

API レスポンスのバリアント型

// lib/schemas.ts
import { z } from "zod";

// 成功 / エラーの2パターン
const ApiResponseSchema = z.discriminatedUnion("status", [
  z.object({
    status: z.literal("success"),
    data: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string().email(),
    }),
  }),
  z.object({
    status: z.literal("error"),
    code: z.string(),
    message: z.string(),
  }),
]);

export type ApiResponse = z.infer<typeof ApiResponseSchema>;

export function parseApiResponse(raw: unknown): ApiResponse {
  return ApiResponseSchema.parse(raw);
}

使用例: API レスポンスの処理

// app/api/user/route.ts
import { NextResponse } from "next/server";
import { parseApiResponse } from "@/lib/schemas";

export async function GET() {
  // 外部 API を呼び出すと仮定
  const raw = {
    status: "success",
    data: { id: 1, name: "田中 太郎", email: "tanaka@example.com" },
  };

  const response = parseApiResponse(raw);

  // status で絞り込むと TypeScript が型を自動的に推論
  if (response.status === "success") {
    // ここでは response.data が確定して使える
    return NextResponse.json(response.data);
  } else {
    // ここでは response.code と response.message が確定
    return NextResponse.json(
      { error: response.message },
      { status: 400 }
    );
  }
}

Server Actions 結果のバリアント型

// lib/actionResult.ts
import { z } from "zod";

// Server Actions の戻り値として使うバリアント
const ActionResultSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("success"),
    message: z.string(),
  }),
  z.object({
    type: z.literal("validation_error"),
    fields: z.record(z.string(), z.array(z.string())),
  }),
  z.object({
    type: z.literal("server_error"),
    message: z.string(),
  }),
]);

export type ActionResult = z.infer<typeof ActionResultSchema>;
// app/actions.ts
"use server";

import type { ActionResult } from "@/lib/actionResult";

export async function submitForm(formData: FormData): Promise<ActionResult> {
  const name = formData.get("name");
  const email = formData.get("email");

  if (!name || !email) {
    return {
      type: "validation_error",
      fields: {
        ...((!name) ? { name: ["名前は必須です"] } : {}),
        ...((!email) ? { email: ["メールアドレスは必須です"] } : {}),
      },
    };
  }

  // 何らかの処理...

  return { type: "success", message: "送信しました" };
}
// app/page.tsx
"use client";

import { useState } from "react";
import { submitForm } from "./actions";
import type { ActionResult } from "@/lib/actionResult";

export default function Page() {
  const [result, setResult] = useState<ActionResult | null>(null);

  async function handleSubmit(formData: FormData) {
    const res = await submitForm(formData);
    setResult(res);
  }

  return (
    <main className="mx-auto max-w-md p-8">
      <form action={handleSubmit} className="space-y-4">
        <input name="name" placeholder="名前" className="w-full rounded border p-2 text-sm" />
        <input name="email" placeholder="メール" className="w-full rounded border p-2 text-sm" />
        <button type="submit" className="rounded bg-blue-600 px-4 py-2 text-sm text-white">
          送信
        </button>
      </form>

      {result && (
        <div className="mt-4 rounded border p-3 text-sm">
          {result.type === "success" && (
            <p className="text-green-600">{result.message}</p>
          )}
          {result.type === "validation_error" && (
            <ul className="text-red-600">
              {Object.entries(result.fields).map(([field, errors]) =>
                errors.map((err) => <li key={`${field}-${err}`}>{err}</li>)
              )}
            </ul>
          )}
          {result.type === "server_error" && (
            <p className="text-red-600">{result.message}</p>
          )}
        </div>
      )}
    </main>
  );
}

ポイント

  • z.discriminatedUnion("status", [...]) の第1引数が discriminator フィールド名。このフィールドの値で型が一意に決まる
  • z.union は全パターンを順番にパースするため遅く、エラーメッセージが複雑。discriminatedUnion は discriminator フィールドで先に絞るため高速で正確
  • if (response.status === "success") のように discriminator で絞り込むと、TypeScript が自動的に残りのフィールドを推論する(型ガード不要)
  • z.literal("success") を使うことで、文字列リテラル型として厳密に型付けされる
  • Server Actions の戻り値パターンとして特に有用。成功 / バリデーションエラー / サーバーエラーを型安全に分岐できる

注意点

z.union と違い、discriminatedUnion は discriminator フィールドで先に型を絞るため TypeScript の型推論が正確で、エラーメッセージも分かりやすい。

関連サンプル