📚

タイプセーフにZodを使う

2024/12/06に公開

Zodとは

Zod は、TypeScript ファーストのスキーマ宣言および検証ライブラリです。ここでは、スキーマという用語を、単純なものから複雑なネストされたオブジェクトまで、あらゆるデータ型を広く指すために使用しています。

https://github.com/colinhacks/zod?tab=readme-ov-file#introduction

ZodREADMEには、上記のような記載があります。

私はanyunknownobjectに対して、バリデーションや型付けをする際に使用する事が多いです。その使用方法をサンプルコードと合わせて説明します。

本記事は、"zod": "3.23.8"のバージョンで検証した内容です。

基本的な使用方法

unsafeValuestringである事を検証します。以下のような流れです。

  1. stringSchemaを定義
  2. stringSchema.safeParseする
import { z } from 'zod';

// スキーマを定義する
const stringSchema = z.string();

const main = (unsafeValue: unknown) => {
  // スキーマを使用する
  const parsedValue = stringSchema.safeParse(unsafeValue);

  if (parsedValue.success) {
    // parsedValue.successがtrueの場合
    // parsedValue.dataがstring 型
    parsedValue.data;
  }
};

TypeScript プレイグラウンド

Zodを使用しない方法

typeofを使用すれば、同等の処理が可能です。

const main = (unsafeValue: unknown) => {
  if (typeof unsafeValue === 'string') {
    // unsafeValueは、 string 型
    unsafeValue;
  }
};

TypeScript プレイグラウンド

objectの検証

基本的な使用方法は、Zodを使用するにはややオーバースペックです。現実的な利用シーンとしてはobjectの検証です。objectの場合typeofなどで検証するには、コード量が増えてしまいます。Zodのスキーマを使用してコードを簡潔に定義できる事は魅力的です。

以下のような事にZodのパワーを発揮しやすいです。

  • POST用のパラメタ
  • Formの入力値
  • APIレスポンス

検証用にProfileタイプを定義しました。処理は以下のような流れです。

  1. 基本的な使用方法と同様にprofileSchemaを定義
  2. profileSchema.safeParseする

特筆事項は、z.objectを使用し、z.object内に、Profileに合わせた検証を定義します。

import { z } from 'zod';

// 検証したい型
type Profile = {
  name: string;
  nickname?: string | undefined;
  type: 'Red' | 'Green';
};

// スキーマを定義する
const profileSchema = z.object({
  name: z.string(),
  nickname: z.string().optional(),
  typo: z.union([z.literal('Red'), z.literal('Green')]),
});

const main = (unknownValue: unknown) => {
  // スキーマを使用する
  const parsedValue = profileSchema.safeParse(unknownValue);

  if (parsedValue.success) {
    // parsedValue.successがtrueの場合
    // parsedValue.dataがProfile 型 なはず...
    parsedValue.data;
  }
};

TypeScript プレイグラウンド

厳格なobjectの検証

z.objectの使用は、コードが簡潔になり良いと思います。しかし、objectの検証には、問題があります。profileSchematypeタイポしています。Profile型とprofileSchemaは、の関連が無いためです。
対策として以下のように関連を持たせる必要があります。そうしないと、TypeScriptの恩恵を受けることが出来なくなります。

type Profile = {
  name: string;
  nickname?: string | undefined;
  type: 'Red' | 'Green';
};

const profile = {
  name: 'サトシ',
  nickname: 'レッド',
  type: 'Red',
} satisfies Profile; // Profile型のobjectである

TypeScript プレイグラウンド

以下のように、z.ZodSchema<Profile>を追加し、Profile型と関連付けました。こうする事で、typeタイポに気が付きます。

import { z } from 'zod';

type Profile = {
  name: string;
  nickname?: string | undefined;
  type: 'Red' | 'Green';
};

const badProfileSchema = z.object({
  name: z.string(),
  nickname: z.string().optional(),
  // typeのタイポに気がつく
  typo: z.union([z.literal('Red'), z.literal('Green')]),
}) satisfies z.ZodSchema<Profile>; // Profile型のSchemaである

const goodProfileSchema = z.object({
  name: z.string(),
  nickname: z.string().optional(),
  // typeをタイポしない
  type: z.union([z.literal('Red'), z.literal('Green')]),
}) satisfies z.ZodSchema<Profile>; // Profile型のSchemaである

TypeScript プレイグラウンド

さらに厳格なobjectの検証

z.ZodSchemaを使用すれば、タイプセーフを実現できそうです。しかし、厳格なobjectの検証にも実は、改善の余地があります。Profileに存在しない項目secretValueがあってもエラーになりません。

import { z } from 'zod';

type Profile = {
  name: string;
  nickname?: string | undefined;
  type: 'Red' | 'Green';
};

export const profileSchema = z.object({
  name: z.string(),
  nickname: z.string().optional(),
  type: z.union([z.literal('Red'), z.literal('Green')]),
  // Profileには存在しないがエラーではない
  secretValue: z.string(),
}) satisfies z.ZodSchema<Profile>;

TypeScript プレイグラウンド

対策としては、以下を組み合わせる必要があります。
それなりに難しい内容だと思いますので、不要な場合は読み飛ばしてください。

  • z.infer
    • Zodのスキーマから、プリミティブなTypeScriptの型を生成する
  • type-festIsEqual
    • 型の比較に使用 (type-challengesでも同等の型が使用されています。)
  • Assert
    • z.inferで生成された型とProfileの型が一致しない場合エラーにする

secretValueを削除すると、タイプエラーは消えます。

import { z } from 'zod';
import { type IsEqual } from 'type-fest';

type Assert<T extends true> = T;

type Profile = {
  name: string;
  nickname?: string | undefined;
  type: 'Red' | 'Green';
};

const strictProfileSchema = z.object({
  name: z.string(),
  nickname: z.string().optional(),
  type: z.union([z.literal('Red'), z.literal('Green')]),
  // Profileには存在しない
  secretValue: z.string(),
}) satisfies z.ZodSchema<Profile>;

// strictProfileSchemaとProfileの厳密な検証
type StrictProfileSchemaChecker = Assert<
  // secretValueがあるので、エラーになる
  IsEqual<Profile, z.infer<typeof strictProfileSchema>>
>;

TypeScript プレイグラウンド

どこまでの厳密さを求めるべきか

以下のような点を考慮し使用箇所を決める事が良いと思います。

z.ZodSchemaのみ

デメリットを理解していれば、大体の場合これで十分だと思います。

type-festIsEqualを使用

必要に応じて局所的な使用でも良いと思います。

TypeScriptのタイプファースト VS Zodのスキーマファースト

さらに厳格なobjectの検証で使用したz.inferを使用すれば、以下のようにprofileSchemaから、Profile型を生成可能です。

import { z } from 'zod';

const profileSchema = z.object({
  name: z.string(),
  nickname: z.string().optional(),
  type: z.union([z.literal('Red'), z.literal('Green')]),
});

type Profile = z.infer<typeof profileSchema>;

TypeScript プレイグラウンド

しかし、私は以下を考慮してz.inferからプリミティブ型を生成せずに、自分で型を定義します。

  • TypeScriptの方がZodより確実に長寿
  • 自動生成生成された型の検証をしたい場合相性が悪い
    • API定義ファイルなど
  • Zodのロックインが強すぎる
    • ライブラリを変更したい場合困難になる

この点を考慮して、タイプファーストで実装し、z.ZodSchemaと組み合わせて使用しています。
z.inferは、type-festIsEqualと組み合わせて厳格な確認をしたい時に使用します。この使用法ならば、タイプファーストで実装可能です。実装ではなく型に依存するほうが堅牢です。

ライブラリを使用する際は、お別れまでを考慮し使用するのが良いと思います。

プリミティブ型

https://typescriptbook.jp/reference/values-types-variables/primitive-types

Zod と テスト

Zodは間違えないが、Zodの使用方法を間違えることがあります。タイポもその一種です。
例えば、z.string().min(3)は3文字は、セーフだろうか?
など考えてしまいます。ですので、実装したスキーマが期待通りの挙動かテストを書きます。
厳格な型チェックも重要ですがテストの方がコスパは良いと思います。

テスタビリティの低いコード

先ほど作ったprofileSchemaをテストしています。以下のような課題があります。

  • テストケースが不足している
  • 何がエラーの原因かわからない

このような場合は、テスタビリティを上げる修正が必要です。

profile.test.ts
import { describe, expect, test } from 'vitest';
import { profileSchema } from './profile';

describe('profileSchema', () => {
  test('有効な値', () => {
    const parsedProfile = profileSchema.safeParse({
      name: 'サトシ',
      type: 'Red',
    });
    expect(parsedProfile.success).toEqual(true);
  });

  test('無効な値', () => {
    const parsedProfile = profileSchema.safeParse({
      name: 'シゲル',
      type: 'Blue',
    });
    expect(parsedProfile.success).toEqual(false);
  });
});

テスタビリティの高いコード

profileSchemaの要素を分割し、個別にテストします。profileSchemaは、小さなスキーマの集合体です。小さなスキーマとは、基本的な使用方法stringSchemaです。その粒度に分割します。

profile.ts
import { z } from 'zod';

export type Profile = {
  name: string;
  nickname?: string | undefined;
  type: 'Red' | 'Green';
};

// 個別に分割
export const nameSchema = z.string();
// 個別に分割
export const nicknameSchema = z.string().optional();
// 個別に分割
export const typeSchema = z.union([z.literal('Red'), z.literal('Green')]);

export const profileSchema = z.object({
  name: nameSchema,
  nickname: nicknameSchema,
  type: typeSchema,
}) satisfies z.ZodSchema<Profile>;

分割された個別のスキーマを個々にテストします。私が気をつけている点は以下です。

  • describe1つに対して、1つのスキーマを検証する
    • describe.concurrentを使用するのも良いと思います
  • transformを使用していなければ、successの判断のみで良いと思います
  • validProfileのような定義をしテスト全体の可読性をあげる
  • 分割したスキーマのテストはしっかり profileSchemaのテストはあっさり
  • test.eachを使う
profile.test.ts
import { describe, expect, test } from 'vitest';
import {
  nameSchema,
  nicknameSchema,
  typeSchema,
  profileSchema,
  type Profile,
} from './profile';

const validProfile = {
  name: 'サトシ',
  nickname: 'レッド',
  type: 'Red',
} satisfies Profile;

describe.concurrent('nameSchema', () => {
  type TestData = {
    param: unknown;
    // `transform`を使用していない場合`success`のみを検証しています。
    expected: ReturnType<(typeof nameSchema)['safeParse']>['success'];
  };

  const testData: TestData[] = [
    {
      param: validProfile.name,
      expected: true,
    },
    // その他様々なテストデータ
  ];

  test.each(testData)('入力と出力 %o', ({ param, expected }) => {
    expect(nameSchema.safeParse(param).success).toMatchObject(expected);
  });
});

describe.concurrent('nicknameSchema', () => {
  type TestData = {
    param: unknown;
    expected: ReturnType<(typeof nicknameSchema)['safeParse']>['success'];
  };

  const testData: TestData[] = [
    {
      param: validProfile.nickname,
      expected: true,
    },
    // その他様々なテストデータ
  ];

  test.each(testData)('入力と出力 %o', ({ param, expected }) => {
    expect(nicknameSchema.safeParse(param).success).toMatchObject(expected);
  });
});

describe.concurrent('typeSchema', () => {
  type TestData = {
    param: unknown;
    expected: ReturnType<(typeof typeSchema)['safeParse']>['success'];
  };

  const testData: TestData[] = [
    {
      param: validProfile.type,
      expected: true,
    },
    // その他様々なテストデータ
  ];

  test.each(testData)('入力と出力 %o', ({ param, expected }) => {
    expect(typeSchema.safeParse(param).success).toMatchObject(expected);
  });
});

describe.concurrent('profileSchema', () => {
  type TestData = {
    param: unknown;
    expected: ReturnType<(typeof profileSchema)['safeParse']>['success'];
  };

  // このテストは、あっさりと
  const testData: TestData[] = [
    {
      param: validProfile,
      expected: true,
    },
    {
      param: null,
      expected: false,
    },
  ];

  test.each(testData)('入力と出力 %o', ({ param, expected }) => {
    expect(profileSchema.safeParse(param).success).toMatchObject(expected);
  });
});

Zod とのお別れ

Zodは、バリデーションライブラリのデファクトスタンダードだと思います。エコシステムも豊富で選択する事が多いです。しかしZodにも課題があります。

ですので、TypeScriptのタイプファースト VS Zodのスキーマファーストに記載した以下の内容を試してみます。

ライブラリを使用する際は、お別れまでを考慮し実装するのが良いと思います。

類似のライブラリである、Valibotに移行します。

import * as v from 'valibot';
import { type IsEqual } from 'type-fest';

type Assert<T extends true> = T;

export type Profile = {
  name: string;
  nickname?: string | undefined;
  type: 'Red' | 'Green';
};

export const nameSchema = v.string();
// Zodのオブジェクトチェインとは違う記法です
export const nicknameSchema = v.optional(v.string());
export const typeSchema = v.union([v.literal('Red'), v.literal('Green')]);

export const profileSchema = v.object({
  name: nameSchema,
  nickname: nicknameSchema,
  type: typeSchema,
}) satisfies v.GenericSchema<Profile>;

// Zodと類似の検証が可能です。
export type ProfileSchemaChecker = Assert<
  IsEqual<Profile, v.InferOutput<typeof profileSchema>>
>;

TypeScript プレイグラウンド

同じテストデータを使用する事で、安心して移行ができます。十分なテストがあれば、同等のライブラリに移行する際に敷居が下がります。

Profile型は簡単なので、効果は薄いかもしれません。しかし、スキーマが複雑になるとテスト無しでは移行する際に不安が残ります。

import { describe, expect, test } from 'vitest';
import { nameSchema, type Profile } from './profile';
import * as v from 'valibot';

const validProfile = {
  name: 'サトシ',
  nickname: 'レッド',
  type: 'Red',
} satisfies Profile;

describe.concurrent('nameSchema', () => {
  type TestData = {
    param: unknown;
    // valivotのSafeParseResultを使用する
    expected: v.SafeParseResult<typeof nameSchema>['success'];
  };

  // Zodの検証で使用した同じテストデータを使用する
  const testData: TestData[] = [
    {
      param: validProfile.name,
      expected: true,
    },
  ];

  test.each(testData)('入力と出力 %o', ({ param, expected }) => {
    // valivotのsafeParseを使用する
    expect(v.safeParse(nameSchema, param).success).toMatchObject(expected);
  });
});

// 以下は、省略

最後に

Zodからの移行についても書きましたが、課題が解決されるかもしれません。Zodのほうが情報を集めやすい場合が多いです。

以下を考慮すれば、ライブラリとうまく付き合えると思います。

  • ライブラリを使用する際はお別れまでを考慮
  • テスタビリティの高いコードを書く
  • テストを書く
chot Inc. tech blog

Discussion