RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

【React】チュートリアルから学ぶZodの基本

はじめに

こんにちは。フロントエンド開発課に所属している新卒1年目のm_you_sanと申します。
実務でZodを使う機会が多いので、基本から理解しようと思い、チュートリアルに取り組んでみました。
今回はZodの基本について、チュートリアルのコードを交えて解説したいと思います。

Zodとは

Zodとは、端的に言うとtypescriptファーストのスキーマ宣言及びバリデーションライブラリです。
Zodはtypescriptの型システムを活用して、フォームの入力値などアプリケーションで扱うデータが、宣言したスキーマに沿っているかを検証することができます。
また、後で解説しますが、Zodではスキーマから型を生成できるという便利な機能があります。
一応、同じバリエーションライブラリのYupでも、スキーマから型生成は行えますが型推論が弱いとされています。
一方で、YupはZodと比べてAPIが豊富という長所があるので、お好みで使い分けると良いと思います。

Zodのチュートリアルについて

ここから、チュートリアルのコードを一部抜粋しながら、Zod基本について解説します。
なお、このチュートリアルは問題のコードが与えられて、テストが通るように修正するという形式になっています(全部で14問あります)。
気になる方は、こちらをcloneして取り組んでみてください。

プリミティブ型の検証(チュートリアル01)

最も基本的な使い方です。

const numberParser = z.number();

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

始めにnumber型が期待されるスキーマを宣言しています。
toString関数では、引数で渡ってきたunknown型のnumをparseの引数に設定しています。
このparseは、引数に渡された値がnumberParserが期待する型、つまりnumber型であった場合は、その値を返し、そうでなかった場合はエラーを返します。
そのため、1つ目のテストではstring型の"123"がtoStringに渡されるので、エラーが返されます。

オブジェクトの検証(チュートリアル02)

オブジェクトのスキーマを宣言する際は、z.objectを使用し、プロパティにもそれぞれスキーマを作成します。
チュートリアルのコードでは、APIのレスポンスが{ name: string }になっているかを検証しています。

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

export const fetchStarWarsPersonName = async (id: string) => {
  const data = await fetch(
    "https://www.totaltypescript.com/swapi/people/" + id + ".json",
  ).then((res) => res.json());

  const parsedData = PersonResult.parse(data);

  return parsedData.name;
};

配列の検証(チュートリアル03)

配列のスキーマを作成するときは、z.arrayを使用します。
チュートリアルのコードでは、APIのレスポンスが{ results: { name: string }[] }の形になっているかを検証しています。

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

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

export const fetchStarWarsPeople = async () => {
  const data = await fetch(
    "https://www.totaltypescript.com/swapi/people.json",
  ).then((res) => res.json());
  
  const parsedData = StarWarsPeopleResults.parse(data);

  return parsedData.results;
};

スキーマから型を生成(チュートリアル04)

Zodでは、z.inferの型引数にスキーマを渡すことで、そのスキーマから型を推論して、型を生成することができます。
チュートリアルのコードでは、StarWarsPeopleResultsから型を生成しているため、dataTypeの型は{ results: { name: string }[] }になります。

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

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

type dataType = z.infer<typeof StarWarsPeopleResults>

この型生成は、実務でもよく使っており、フォームの入力値を検証するスキーマから型生成し、その型をAPIリクエストを送る関数の引数の型にするといったような使い方をしています。

//フォームの入力値を検証するスキーマ
const PersonSchema = z.object({
  name: z.string(),
  hometown: z.string(),
});

type PersonSchemType = z.infer<typeof PersonSchema>;

const handleSubmit = (formData: PersonSchemType) => {
  //APIリクエストなどの処理
}

オプショナル(チュートリアル05)

オプショナルでスキーマを定義する際は、optionalを使用します。
チュートリアルのコードでは、{ name: string, phoneNumber?: string }を期待するスキーマを定義しています。
phoneNumberがオプショナルになったことで、validateFormInputにnameのみを渡したとしても、エラーが発生しなくなります。

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

export const validateFormInput = (values: unknown) => {
  const parsedData = Form.parse(values);

  return parsedData;
};

デフォルト値を設定する(チュートリアル06)

スキーマにデフォルト値を設定する場合は、defaultを使用します。
チュートリアルでは、keywordsのデフォルト値に空配列を設定しています。

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

ユニオン型の検証(チュートリアル07)

ユニオン型のスキーマを作成する際は、z.enumもしくはz.unionを使用します。
チュートリアルのコードでは、privacyLevel: "private" | "public"を期待するユニオン型のスキーマを定義しています。
テストを実行すると、privacyLevelにprivateとpublic以外の値を渡すとエラーを返すことが分かります。
このユニオン型のスキーマ定義ですが、実務ではプルダウンメニューの項目を検証する際によく見かけます。

const Form = z.object({
  repoName: z.string(),
  privacyLevel: z.enum(["private", "public"])
  //↓の書き方でも可
  //privacyLevel: z.union([z.literal("private"), z.literal("public")])
});

特定の条件を満たしているか検証する(チュートリアル08)

最小値、最大値など特定の条件を満たしているかを検証する方法です。
このスキーマは、nameに最低1文字、phoneNumberに5文字以上20文字以下、emailに「x@x.x」の形式、websiteに「fuga://hoge」の形式を期待するものです。
テストを確認すると、条件を満たしていないとエラーを返すことが分かります。

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(),
});

スキーマの拡張(チュートリアル09)

一度定義したスキーマは、extendやmergeを使用して拡張させることができます。

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

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

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

バリデーション前後で値を変換する(チュートリアル10)

Zodでは、バリデーション前後で値を変換することができます。
チュートリアルのコードでは、transformを使ってバリデーション後に値を変換しています。
具体的には、parseに渡しているdataがStarWarsPeopleResultsで期待している型({ result: { name: string }[] })だった場合、{ results: [ { name: "foo hoge", nameAsArray: ["foo", "hoge"] } ] }のような形 で返却するという流れになっています。

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

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

export const fetchStarWarsPeople = async () => {
  const data = await fetch(
    "https://www.totaltypescript.com/swapi/people.json",
  ).then((res) => res.json());

  const parsedData = StarWarsPeopleResults.parse(data);

  return parsedData.results;
};

独自のバリデーションチェックを行う(チュートリアル11、12)

Zodでは、refineを使うことで独自のバリデーションチェックを作成することができます。
refineはparseを実行した際に、条件式がfalseの場合、エラーを返します。
チュートリアル11のコードでは、passwordとconfirmPasswordが不一致の場合、エラーメッセージを返すという形になっています。
refineは相関チェックを行う際によく使われている印象です。

const Form = z.object({
  password: z.string(),
  confirmPassword: z.string(),
}).refine(({ password, confirmPassword }) => 
  password === confirmPassword,
  {
    message: "Passwords don't match"
  } 
);

またrefineは、チュートリアル12のように、非同期関数を渡すことができます。
doesStarWarsPersonExistは、parseAsyncに渡されたidでAPIを叩き、dataがあった場合はtrueを返し、なかった場合はfalseを返します。
doesStarWarsPersonExistがfalseの場合、refineはエラーメッセージのNot Foundを返します。

const doesStarWarsPersonExist = async (id: string) => {
  try {
    const data = await fetch(
      "https://www.totaltypescript.com/swapi/people/" + id + ".json",
    ).then((res) => res.json());
    return Boolean(data?.name);
  } catch (e) {
    return false;
  }
};

const Form = z.object({
  id: z.string().refine(doesStarWarsPersonExist, { message: "Not found"}),
});

export const validateFormInput = async (values: unknown) => {
  const parsedData = await Form.parseAsync(values);

  return parsedData;
};

再帰的なスキーマを宣言する(チュートリアル13)

再帰的なスキーマを宣言する場合、lazyを使用します。
lazyのみだとMenuItemがany型になるため、少し手間がかかりますが、手動でMenuItemTypeを定義して、その型情報をMenuItemに渡す必要があります。

interface MenuItemType {
  link: string;
  label: string;
  children?: MenuItemType[];
}

const MenuItem: z.ZodType<MenuItemType> = z.lazy(() => 
  z.object({
    link: z.string(),
    label: z.string(),
    children: z.array(MenuItem).default([]),
  })
) 

ジェネリクスチュートリアル14)

スキーマジェネリクスで渡したい場合は、ジェネリクスの型にextends ZodSchemaextends ZodTypeAnyでも可)で制約を加えます。
チュートリアルのコードでは、ジェネリクスで渡したスキーマから、z.inferで型を生成して、戻り値の型にしています。

const genericFetch = <TSchema extends z.ZodSchema>(
  url: string,
  schema: TSchema
): Promise<z.infer<TSchema>> => {
  return fetch(url)
    .then((res) => res.json())
    .then((result) => schema.parse(result));
};

まとめ

チュートリアルのコードを交えて、Zodの基本について紹介させていただきました。
再帰的なスキーマ宣言やジェネリクスなど、やや応用的な部分もありましたが、私自身もチュートリアルを通じて、基本的な部分はさらうことができたと思っています。
今回は紹介しませんでしたが、ZodはReact Hook Formと組み合わせることができるので、気になる方はこちらの記事も是非読んでみてください。

tech-blog.rakus.co.jp

Copyright © RAKUS Co., Ltd. All rights reserved.