概要
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 の戻り値パターンとして特に有用。成功 / バリデーションエラー / サーバーエラーを型安全に分岐できる