タイプセーフにZodを使う
Zod
とは
Zod は、TypeScript ファーストのスキーマ宣言および検証ライブラリです。ここでは、スキーマという用語を、単純なものから複雑なネストされたオブジェクトまで、あらゆるデータ型を広く指すために使用しています。
https://github.com/colinhacks/zod?tab=readme-ov-file#introduction
Zod
のREADME
には、上記のような記載があります。
私はany
やunknown
のobject
に対して、バリデーションや型付けをする際に使用する事が多いです。その使用方法をサンプルコードと合わせて説明します。
本記事は、"zod": "3.23.8"
のバージョンで検証した内容です。
基本的な使用方法
unsafeValue
がstring
である事を検証します。以下のような流れです。
-
stringSchema
を定義 -
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;
}
};
Zodを使用しない方法
typeof
を使用すれば、同等の処理が可能です。
const main = (unsafeValue: unknown) => {
if (typeof unsafeValue === 'string') {
// unsafeValueは、 string 型
unsafeValue;
}
};
object
の検証
基本的な使用方法は、Zod
を使用するにはややオーバースペックです。現実的な利用シーンとしてはobject
の検証です。object
の場合typeof
などで検証するには、コード量が増えてしまいます。Zod
のスキーマを使用してコードを簡潔に定義できる事は魅力的です。
以下のような事にZod
のパワーを発揮しやすいです。
- POST用のパラメタ
- Formの入力値
- APIレスポンス
検証用にProfile
タイプを定義しました。処理は以下のような流れです。
-
基本的な使用方法と同様に
profileSchema
を定義 -
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;
}
};
object
の検証
厳格なz.object
の使用は、コードが簡潔になり良いと思います。しかし、object
の検証には、問題があります。profileSchema
のtype
をタイポしています。Profile
型とprofileSchema
は、の関連が無いためです。
対策として以下のように関連を持たせる必要があります。そうしないと、TypeScript
の恩恵を受けることが出来なくなります。
type Profile = {
name: string;
nickname?: string | undefined;
type: 'Red' | 'Green';
};
const profile = {
name: 'サトシ',
nickname: 'レッド',
type: 'Red',
} satisfies Profile; // Profile型のobjectである
以下のように、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である
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>;
対策としては、以下を組み合わせる必要があります。
それなりに難しい内容だと思いますので、不要な場合は読み飛ばしてください。
-
z.infer
-
Zod
のスキーマから、プリミティブなTypeScript
の型を生成する
-
-
type-fest
のIsEqual
- 型の比較に使用 (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>>
>;
どこまでの厳密さを求めるべきか
以下のような点を考慮し使用箇所を決める事が良いと思います。
z.ZodSchema
のみ
デメリットを理解していれば、大体の場合これで十分だと思います。
type-fest
のIsEqual
を使用
必要に応じて局所的な使用でも良いと思います。
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>;
しかし、私は以下を考慮してz.infer
からプリミティブ型を生成せずに、自分で型を定義します。
-
TypeScript
の方がZod
より確実に長寿 - 自動生成生成された型の検証をしたい場合相性が悪い
- API定義ファイルなど
-
Zod
のロックインが強すぎる- ライブラリを変更したい場合困難になる
この点を考慮して、タイプファーストで実装し、z.ZodSchema
と組み合わせて使用しています。
z.infer
は、type-fest
のIsEqual
と組み合わせて厳格な確認をしたい時に使用します。この使用法ならば、タイプファーストで実装可能です。実装ではなく型に依存するほうが堅牢です。
ライブラリを使用する際は、お別れまでを考慮し使用するのが良いと思います。
プリミティブ型
Zod
と テスト
Zod
は間違えないが、Zod
の使用方法を間違えることがあります。タイポもその一種です。
例えば、z.string().min(3)
は3文字は、セーフだろうか?
など考えてしまいます。ですので、実装したスキーマが期待通りの挙動かテストを書きます。
厳格な型チェックも重要ですがテストの方がコスパは良いと思います。
テスタビリティの低いコード
先ほど作ったprofileSchema
をテストしています。以下のような課題があります。
- テストケースが不足している
- 何がエラーの原因かわからない
このような場合は、テスタビリティを上げる修正が必要です。
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
です。その粒度に分割します。
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>;
分割された個別のスキーマを個々にテストします。私が気をつけている点は以下です。
-
describe
1つに対して、1つのスキーマを検証する-
describe.concurrent
を使用するのも良いと思います
-
-
transform
を使用していなければ、success
の判断のみで良いと思います -
validProfile
のような定義をしテスト全体の可読性をあげる - 分割したスキーマのテストはしっかり
profileSchema
のテストはあっさり -
test.each
を使う
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>>
>;
同じテストデータを使用する事で、安心して移行ができます。十分なテストがあれば、同等のライブラリに移行する際に敷居が下がります。
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.com)のエンジニアブログです。 フロントエンドエンジニア募集中! カジュアル面接申し込みはこちらから chot-inc.com/recruit/iuj62owig
Discussion