TypeScriptのZodで実行時データを検証する

1. 概要

TypeScriptの型はコンパイル後に削除されます。そのため、APIから送られてくる不正な形式のJSON、ユーザーが送信したフォーム、または値が不足している環境変数から保護することはできません。

この記事では、Zod 4を使用してランタイムでデータを検証する方法を説明します。スキーマを一度宣言し、そこから型を推論します。エラー処理を整理された方法で行い、テストを書き、よくある落とし穴を避ける方法も学べます。

2. TypeScriptの型がランタイムでは不十分な理由

次のよくあるコードを考えてみてください。

interface Customer {
  id: number;
  email: string;
}

const raw = '{"id": "42", "email": null}';
const customer = JSON.parse(raw) as Customer;

console.log(customer.id + 1);
console.log(customer.email.toLowerCase());

JSON.parseはanyを返します。as Customerはコンパイラへの約束に過ぎず、実際の検証ステップはありません。実行結果は以下の通りです。

421
TypeError: Cannot read properties of null (reading 'toLowerCase')

idは文字列"42"なので、加算は文字列連結になります。emailはnullなのでプログラムがクラッシュします。コンパイラは何も報告しません。実行時に実際に検証を実行するレイヤーが必要です。Zodはそれを正確に行います。

3. インストール

Zodには他の依存関係がなく、Node.jsとブラウザの両方で実行できます。

npm install zod

Zodが型を正しく推論するためには、tsconfig.jsonでストリクトモードを有効にする必要があります。

{
  "compilerOptions": {
    "strict": true
  }
}

注意: "strict": trueがないと、オプショナルフィールドが誤って推論される可能性があります。型の利点の大部分を失います。

4. スキーマの定義と型推論

スキーマは、期待するデータの形状を説明します。

import { z } from "zod";

const CustomerSchema = z.object({
  id: z.number().int().positive(),
  email: z.email(),
  fullName: z.string().min(2).max(100),
  phone: z.string().optional(),
  tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).default("FREE"),
});

type Customer = z.infer<typeof CustomerSchema>;

各部分は次のように機能します。

  • z.number().int().positive()は正の整数を要求します。
  • z.email()は文字列が有効なメールアドレス形式であるかを確認します。Zod 4では、email、URL、UUIDなどの文字列形式は最上位レベルで宣言されます。
  • .optional()はフィールドが存在しない、またはundefinedの値を持つことを許可します。
  • .default("FREE")は、入力にtierフィールドがない場合に自動的に値を入力します。
  • z.inferはスキーマからTypeScriptの型を推論します。

Customerにホバーすると、同等の型が表示されます。

type Customer = {
  id: number;
  email: string;
  fullName: string;
  phone?: string | undefined;
  tier: "FREE" | "PRO" | "ENTERPRISE";
}

強みはここにあります。スキーマが唯一の真実の源です。スキーマにフィールドを追加すると、型が自動的に更新されます。並列してinterfaceを保守する必要はありません。

5. parseとsafeParse

Zodには2つの主な検証方法があります。

5.1. parse:エラー時に例外をスロー

const customer = CustomerSchema.parse({
  id: 42,
  email: "an.nguyen@example.com",
  fullName: "Nguyễn Văn An",
});

console.log(customer);
{ id: 42, email: 'an.nguyen@example.com', fullName: 'Nguyễn Văn An', tier: 'FREE' }

戻り値は検証済みで、Customer型を持ちます。tierフィールドはデフォルト値で満たされます。データが不正な場合、parseはZodErrorをスロー します。

5.2. safeParse:結果を返す、エラーをスローしない

const result = CustomerSchema.safeParse({ id: "42", email: "not-an-email" });

if (!result.success) {
  console.log(result.error.issues.map((i) => `${i.path.join(".")}: ${i.code}`));
} else {
  console.log(result.data.email);
}
[ 'id: invalid_type', 'email: invalid_format', 'fullName: invalid_type' ]

resultは判別型ユニオンです。result.successをチェックした後、TypeScriptは確実にどのブランチにdataがあり、どのブランチにerrorがあるかを知ります。また、Zodは最初のエラーで停止せず、すべてのエラーを収集することに注意してください。これはフォームにエラーを表示する際に非常に便利です。

選択ルールは簡単です。

  • システムの境界、例えばリクエストボディやフォームでsafeParseを使用してください。エラーが正常です。
  • データが不正であることがバグまたは設定の破損を意味し、アプリケーションが直ちに停止すべき場合はparseを使用してください。

6. カスタムエラーメッセージとエラー形式

Zodのデフォルトメッセージは英語で書かれており、かなり技術的です。エラーメッセージを置き換えるために、errorパラメータを渡すことができます。

const SignUpSchema = z.object({
  email: z.email({ error: "Email không hợp lệ" }),
  password: z.string().min(8, { error: "Mật khẩu tối thiểu 8 ký tự" }),
});

const result = SignUpSchema.safeParse({ email: "abc", password: "123" });

if (!result.success) {
  console.log(z.flattenError(result.error));
}
{
  formErrors: [],
  fieldErrors: {
    email: [ 'Email không hợp lệ' ],
    password: [ 'Mật khẩu tối thiểu 8 ký tự' ]
  }
}

z.flattenErrorはエラーをフィールド名でグループ化します。この形式はフロントエンドに返すのに適しています。ログが必要な場合は、z.prettifyError(result.error)を使用して読みやすい複数行の文字列を取得できます。

7. Coerce、transform、refine

実際のデータはスキーマと完全に一致することは稀です。Zodはこの状況に対処するための3つのツールを持っています。

7.1. z.coerce:入力を強制的に変換

クエリ文字列と環境変数は常に文字列です。z.coerceは検証の前にそれらを変換します。

const PaginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  size: z.coerce.number().int().max(100).default(20),
});

console.log(PaginationSchema.parse({ page: "3", size: "50" }));
{ page: 3, size: 50 }

注意: z.coerce.number()はNumber(input)を呼び出します。したがって、空の文字列""は0になり、型チェックを通過します。このケースをブロックするために、常に.min(1)のような制約を追加してください。

7.2. transform:検証後の変換

const OrderCodeSchema = z
  .string()
  .trim()
  .regex(/^ord-\d{6}$/i, { error: "Mã đơn không đúng định dạng" })
  .transform((code) => code.toUpperCase());

console.log(OrderCodeSchema.parse("  ord-000123 "));
ORD-000123

transformを使用した後、入力型と出力型は異なる可能性があります。z.input<typeof S>とz.output<typeof S>を使用して、各型を個別に取得できます。z.inferはz.outputと同等です。

7.3. refine:カスタムビジネスルール

同時に複数のフィールドに関連する制約があります。これを処理するために、オブジェクトレベルでrefineを配置します。

const BookingSchema = z
  .object({
    checkIn: z.coerce.date(),
    checkOut: z.coerce.date(),
  })
  .refine((b) => b.checkOut > b.checkIn, {
    error: "Ngày trả phòng phải sau ngày nhận phòng",
    path: ["checkOut"],
  });

const r = BookingSchema.safeParse({ checkIn: "2026-11-10", checkOut: "2026-11-08" });
console.log(r.success, r.error?.issues[0].path, r.error?.issues[0].message);
false [ 'checkOut' ] Ngày trả phòng phải sau ngày nhận phòng

pathは、オブジェクト全体ではなくcheckOutフィールドにエラーを取り付けます。こうすることで、フロントエンドはエラーを正しい場所に表示できます。

8. 実践例

8.1. 注文作成リクエストの検証

以下の例は、フレームワークに依存しないリクエストハンドラー関数です。これはExpress、Fastify、またはNext.jsで使用できます。

import { z } from "zod";

const OrderItemSchema = z.object({
  productId: z.uuid(),
  quantity: z.number().int().min(1).max(99),
});

export const CreateOrderSchema = z.object({
  customerId: z.number().int().positive(),
  items: z.array(OrderItemSchema).nonempty({ error: "Đơn hàng phải có ít nhất 1 sản phẩm" }),
  note: z.string().max(500).optional(),
});

export type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;

export function handleCreateOrder(body: unknown) {
  const result = CreateOrderSchema.safeParse(body);
  if (!result.success) {
    return { status: 400, body: z.flattenError(result.error).fieldErrors };
  }
  const order: CreateOrderRequest = result.data;
  return { status: 201, body: { itemCount: order.items.length } };
}

bodyパラメータにunknown型があり、anyではないことに注意してください。これにより、TypeScriptはあなたが使用する前に検証することを強制します。safeParseの後、result.dataは完全な型を持ちます。デフォルトではz.objectも不正なフィールドを削除するため、クライアントはオブジェクトにisAdmin: trueを「詰め込む」ことはできません。

8.2. 起動時の環境変数の検証

アプリケーションは設定が間違っている場合はすぐに停止するべきで、最初のリクエストを処理するときに発見されるのを待たないでください。

import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  PORT: z.coerce.number().int().default(3000),
  DATABASE_URL: z.url(),
});

export const env = EnvSchema.parse(process.env);

DATABASE_URLが不足していると仮定しましょう。アプリケーションはモジュールをインポートするときに直ちにZodErrorをスロー し、DATABASE_URLが不足していることを明確に示すメッセージが表示されます。他の場所では、env.PORTはstring | undefinedではなくnumber型です。

9. Vitestでテストを書く

スキーマはビジネスロジックを含むため、テストも必要です。まずVitestをインストールしてください。

npm install -D vitest

次に、CreateOrderSchemaのテストを書きます。

import { describe, it, expect } from "vitest";
import { z } from "zod";
import { CreateOrderSchema } from "./order";

describe("CreateOrderSchema", () => {
  const validItem = { productId: "3f1c2a9e-8b4d-4c1a-9f2e-1a2b3c4d5e6f", quantity: 2 };

  it("chấp nhận đơn hợp lệ và bỏ field lạ", () => {
    const result = CreateOrderSchema.safeParse({
      customerId: 7,
      items: [validItem],
      isAdmin: true,
    });

    expect(result.success).toBe(true);
    expect(result.data).toEqual({ customerId: 7, items: [validItem] });
  });

  it("từ chối đơn không có sản phẩm", () => {
    const result = CreateOrderSchema.safeParse({ customerId: 7, items: [] });

    expect(result.success).toBe(false);
    expect(z.flattenError(result.error!).fieldErrors.items).toContain(
      "Đơn hàng phải có ít nhất 1 sản phẩm",
    );
  });

  it("từ chối số lượng vượt giới hạn", () => {
    const result = CreateOrderSchema.safeParse({
      customerId: 7,
      items: [{ ...validItem, quantity: 100 }],
    });

    expect(result.error?.issues[0].path).toEqual(["items", 0, "quantity"]);
  });
});

npx vitest runを実行すると、3つのテストがすべてパスするのが表示されます。3番目のテストでは、エラーのpathが配列内の要素まで遡ることを示しています。これはフロントエンドが正しい入力フィールドを赤くマークするために必要な情報です。

10. よくあるエラーと使用してはいけない場合

スキーマを持った後、asで強制キャストすること。 body as CreateOrderRequestを書くと、保護メカニズム全体が壊れます。常にresult.dataからデータを取得してください。

async refineをparseで使用すること。 たとえば、メールがデータベースに既に存在するかを確認します。async refineがある場合、parseAsyncまたはsafeParseAsyncを呼び出す必要があります。そうしないと、Zodはエラーをスロー します。

余分なフィールドが拒否されることを期待すること。 z.objectはデフォルトで不正なフィールドを暗黙的に削除します。余分なフィールドがある場合にエラーを報告したい場合、例えば設定ファイルでスペルミスをキャッチするために、z.strictObject(...)を使用してください。

アプリケーション内で繰り返し検証を行うこと。 システムの境界でのみ検証する必要があります。リクエスト、サードパーティAPIからのレスポンス、メッセージキュー、構成ファイルです。検証済みのデータの型を信頼してください。各関数で再度パースするだけではCPUが浪費されます。

注意: ホットループでZodを使用しないでください。毎秒数十万のオブジェクトを処理する必要があります。検証には実際のコストがあります。その場合は、入力時に一度チェックするか、スキーマをコードにコンパイルするライブラリを選択してください。

関数内にスキーマを宣言すること。 関数が呼び出されるたびにスキーマを作成すると、アプリケーションは追加の初期化コストが発生します。モジュールレベルでスキーマを宣言し、再利用してください。

11. 結論

Zodはコンパイル時の型と実行時の実際のデータの間のギャップを埋めます。スキーマを一度宣言し、z.inferで型を推論し、システムの境界でsafeParseを使用して、クリーンなデータまたは構造化されたエラーリストを取得します。

coerce、transform、refineを使用すると、ほとんどの実際の状況を処理できます。クエリ文字列、データ正規化、複数フィールドのビジネスルールです。早めに検証し、端で検証し、TypeScriptに残りを任せてください。

typescriptzodvalidationnodejs