Zodによるバリデーションに入門する

近いうちにZodを使うことになりそうなので素振りしておきました。
2023.06.19

こんにちは。CX事業本部のKyoです。近いうちにZodを使うことになりそうなので素振りしておきました。

はじめに

ZodはTypeScriptの型システムを活用し、データの形状や構造を静的にチェックすることが可能なライブラリです。これにより、APIからのレスポンスやユーザーからの入力など、アプリケーションで扱うデータが期待する形状であることを保証できます。

Zod公式ドキュメントでは以下のように説明されています。

Zod is a TypeScript-first schema declaration and validation library. I'm using the term "schema" to broadly refer to any data type, from a simple stri"g to a complex nested object.

今回は公式ドキュメントからリンクされていたチュートリアルをやってみました。

全体の流れ

以下のリポジトリに問題が格納されています。私はリポジトリをCloneしてVSCodeで解きましたが、gitpodによるオンライン版も利用可能なようです。

01-number.problem.ts という形で問題のコードが与えられます。問題にはテストも含まれていますが、最初はテストが通らない状態です。これをドキュメントを読みつつ修正し、テストが通る状態にします。コードの中にヒントが書かれていたり、Webにも解説があったりと解きやすいようになっています。また、01-number.solution.tsという形で解答も付いているので安心です。

やってみた

どんな問題(ユースケース)があったのかをざっくりふりかえります。

1. Runtime Type Checking with Zod

toStringが定義されており、この引数がNumber型かどうかのバリデーションを行います。Zodの基本的な使い方ですね。

const numberParser = z.number();

export const toString = (num: unknown) => {
  const parsed = numberParser.parse(num);
  return String(parsed);
};

参考: Numbers

2. Verify Unknown APIs with an Object Schema

Zodの代表的なユースケースであるAPIレスポンスのバリデーションを行います。具体的には、人物情報のAPIレスポンスがnameという文字列のプロパティを必ず含んでいることを保証します。

const PersonResult = z.object({
  name: z.string(),
});

参考: Object

3. Create an Array of Custom Types

APIのレスポンスが配列で人物の情報を返すことのバリデーションを行います。以下の形でAPIレスポンスが想定された型の配列であることを担保しました。

const StarWarsPeopleResults = z.object({
  results: z.array(StarWarsPerson),
});

参考: Arrays

4. Extract a Type from a Parser Object

APIのレスポンスが想定のTypeScriptの型であるかどうかをバリデーションします。ZodのinferメソッドでStarWarsPeopleResultsスキーマ(パーサー)からTypeScriptの型を生成します。これでStarWarsPeopleResultsTypeというTypeScriptの型がZodのスキーマと一致することになります。

const StarWarsPeopleResults = z.object({
  results: z.array(StarWarsPerson),
});

type StarWarsPeopleResultsType = z.infer<typeof StarWarsPeopleResults>;

参考: Type inference

5. Make Schemas Optional

フォーム入力をお題に、nameが必須、phoneNumberが任意というケースのバリデーションを行います。

const Form = z.object({
  name: z.string(),
  phoneNumber: z.string().optional(),
});

参考: optional

6. Set a Default Value with Zod

フォーム入力をお題に、値が存在しない場合はデフォルト値として空の配列をセットするというケースのバリデーションします。

const Form = z.object({
  repoName: z.string(),
  keywords: z.array(z.string()).default([]),
});

参考: default

7. Be Specific with Allowed Types

フォーム入力をお題に、あるプロパティが指定された値の文字列を満たすかどうかをバリデーションします。具体的にはprivacyLevelには、private, publicのどちらかが入っているかを(拡張性の高い方法で)検証しました。enumを使うパターンで解きましたが、unionでも良いようです。

const Form = z.object({
  repoName: z.string(),
  privacyLevel: z.enum(["private", "public"]),
});

参考: unions, enums

8. Complex Schema Validation

フォーム入力をお題に、各プロパティが条件を満たしているかをバリデーションします。

満たすべき条件は以下です。

  • name, phoneNumberが規定の文字数
  • email, websiteが不正な形でないこと
const Form = z.object({
  name: z.string().min(1),
  phoneNumber: z.string().min(5).max(20).optional(),
  email: z.string().email(),
  website: z.string().url().optional(),
});

参考: Strings

9. Reduce Duplicated Code by Composing Schemas

これまでとはやや毛色の異なる問題です。重複しているスキーマをDRYな形にリファクタリングします。元のスキーマに共通していたUUIDをObjectWithIdとして切り出し、extendでプロパティを追加しました。なお異なる型を組み合わせたい場合にはmergeがよいようです。

const ObjectWithId = z.object({
  id: z.string().uuid(),
});

const User = ObjectWithId.extend({
  name: z.string(),
});

const Post = ObjectWithId.extend({
  title: z.string(),
  body: z.string(),
});

const Comment = ObjectWithId.extend({
  text: z.string(),
});

参考: extend, merge

10 . Transform Data from Within a Schema

APIレスポンスのバリデーションと変換をします。具体的にはレスポンスをnameというプロパティをもったオブジェクトのスキーマとしてバリデーションして、そこからtransformで変換を行います。この変換ロジックは、transformの引数の無名関数が担当し、ここではnameを空文字で分割して配列に変換しています。

const StarWarsPerson = z
  .object({
    name: z.string(),
  })
  .transform((person) => ({
    ...person,
    nameAsArray: person.name.split(" "),
  }));

参考: transform

おわりに

今回はチュートリアルを通してZodの機能と使用方法を学びました。このチュートリアルは構成が良く、自然と公式ドキュメントを読み進めて学習する流れが作られていました。またボリュームとしてもちょうどよく、理解しやすかったと思います。