🧩

zodで相関チェックをする(refine)

2022/12/20に公開
1

はじめに

最近zodに入門しました。zodで相関チェックをやる方法を調べたのでその覚書です。

ケースとしては例えば開始日と終了日があって、終了日が開始日より未来かどうかをバリデーションでチェックしてエラーメッセージを出したいというような場合です。

https://github.com/t-shiratori/hello-zod

refineを使って以下のような感じでできました。

export const formSchema = z
  .object({
    startDate: z.string().refine(
      (val) => {
        return val.length > 0;
      },
      { message: "日付を入力してください" }
    ),
    endDate: z.string().refine(
      (val) => {
        return val.length > 0;
      },
      { message: "日付を入力してください" }
    )
  })
  .refine(
    (args) => {
      const { startDate, endDate } = args;
      const startDateObject = dayjs(startDate);
      const endDateObject = dayjs(endDate);
      // 終了日が開始日より未来かどうか
      return endDateObject.isAfter(startDateObject);
    },
    {
      message: "終了日は開始日より未来の日付にしてください",
      path: ["endDate"]
    }
  );

refineについて

zodは.refineを使うことで独自のバリデーションロジックを書くことができます。

const myString = z.string().refine((val) => val.length <= 255, {
  message: "String can't be more than 255 characters",
});

refine()は2つの引数を取ります。

  1. 1つ目は検証ロジックです。期待する条件を書きます。parseを実行した際にこの条件がfalseの場合、エラーが返ります。
  2. 2つ目は複数のオプションを受け取ります。
    パラメーターは以下のように定義されています。
type RefineParams = {
  // override error message
  message?: string;

  // appended to error path
  path?: (string | number)[];

  // params object you can use to customize message
  // in error map
  params?: object;
};
パラメーター 説明
message エラーメッセージ
path エラー扱いにする項目を指定できる
params 何かしら他に渡したい値がある場合に使えるオブジェクト

メッセージの引数部分は以下のようにも書けます。

const longString = z.string().refine(
  (val) => val.length > 10,
  (val) => ({ message: `${val} is not more than 10 characters` })
);

非同期の処理も書けます。その場合は.parseAsyncメソッドを使用してパースします。

const stringSchema1 = z.string().refine(async (val) => val.length < 20);
const value1 = await stringSchema1.parseAsync("hello"); // => hello

const stringSchema2 = z.string().refine(async (val) => val.length > 20);
const value2 = await stringSchema2.parseAsync("hello"); // => throws

.transformとチェーンすることもできます。

z.string()
  .transform((val) => val.length)
  .refine((val) => val > 25);

superRefineについて

.refineよりもより複雑にカスタマイズできるのが.superRefine です。
zodはバリデーションエラーになった場合にZodErrorを返します。エラー項目の情報はZodIssueというデータ構造でZodErrorに保持されています。
superRefineを使えば独自にIssueを作成して好きなだけ追加することができます。

const Strings = z.array(z.string()).superRefine((val, ctx) => {
  if (val.length > 3) {
    ctx.addIssue({
      code: z.ZodIssueCode.too_big,
      maximum: 3,
      type: "array",
      inclusive: true,
      message: "Too many items 😡",
    });
  }

  if (val.length !== new Set(val).size) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: `No duplicates allowed.`,
    });
  }
});

refineでも以下のように複数のバリデーション項目を追加することができますが、Issueのエラーコードなどは設定できません。refineは常にZodIssueCode.customエラーコードのIssueを作成します。ZodIssueCodeにIssueのコード一覧が載っています。

const arrayRefineSchema = z
  .array(z.string())
  .refine((val) => val.length <= 3, {
    message: 'Too many items 😡',
  })
  .refine((val) => val.length == new Set(val).size, {
    message: `No duplicates allowed.`,
  });

Abort early

デフォルトではrefineで複数のバリデーションチェックを追加した場合、前のバリデーションが失敗した後も後続のバリエーションが続行されます。しかしバリデーションエラーが発生した時点で処理を中断してしまいたい場合もあると思います。その場合は、ctx.addIssuefatalフラグをtrueにして、z.NEVERを返すようにします。

const schema = z.number().superRefine((val, ctx) => {
  if (val < 10) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "should be >= 10",
      fatal: true, // ←ここ
    });

    return z.NEVER; // ←ここ
  }

  if (val !== 12) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "should be twelve",
    });
  }
});

さいごに

今関わっているプロダクトではjoiを使っていますが辛いと感じるころもありzodを調べていました。TypeScriptファーストでインターフェースもわかりやすく、ドキュメントもわかりやすいと思います。個人的にはこちらのほうが開発体験も向上しそうで好印象です。

Discussion

nap5nap5

joiなんてものがあるのですね。勉強になりました。

superRefineはエラーチェックの実行順序を制御したいとき、ないしは相関チェックなどでハンディな印象でした。

すこしデモを作ってみました。

$ yarn do-a // 早期リターンできてない
$ yarn do-b // 早期リターンできました

早期リターンできたほうです。

import { Err, Ok, Result } from "neverthrow";
import { z } from "zod";
import { isNullOrUndefined } from "./utils";

const ErrorDataSchema = z.custom<Error>().nullish();
type ErrorData = z.infer<typeof ErrorDataSchema>;
type NutsData = `${number}px`;

const nuts = (data: unknown): Result<NutsData, ErrorData> => {
  const parsed = z
    .custom<NutsData>()
    .superRefine((v, ctx) => {
      if (isNullOrUndefined(v)) {
        ctx.addIssue({
          code: "custom",
          message: `${"Must be required."}`,
          path: ["unit"],
          fatal: true,
        });
        return z.NEVER;
      }
      if (!/^\d+px$/.test(v as string)) {
        ctx.addIssue({
          code: "custom",
          message: `Must be added 'px' to suffix. [${data}]`,
          path: ["unit"],
          fatal: true,
        });
        return z.NEVER;
      }
    })
    .safeParse(data);
  if (!parsed.success) {
    return new Err(
      new Error("Something went wrong...", {
        cause: parsed.error,
      })
    );
  }
  return new Ok(parsed.data);
};

(() => {
  nuts(null).match(
    (data) => {
      console.log(data);
    },
    (error) => {
      console.log(error);
    }
  );
})();

https://codesandbox.io/p/sandbox/intelligent-wave-rk2m8p?file=%2FREADME.md

簡単ですが、以上です。