zodのエラーメッセージをカスタマイズする

フロントエンドのフォームのバリデーションとして`zod`を利用しました。
エラーメッセージのカスタマイズ方法がなかなか見つからなかったので、記しておきます。

なお、使用している`zod`のバージョンは以下です。

```
zod@3.19.1
```
<details><summary>zodとは</summary>

[GitHub](https://github.com/colinhacks/zod)

定義したバリデーションスキーマから`TypeScript`の型を生成してくれます。
これにより、型定義とバリデーション定義を一括管理できます:grinning:

```ts
// バリデーションスキーマ
const userSchema = zod.object({
  // テキストのバリデーション
  userId: zod
    .string()
    .regex(/^[a-zA-Z0-9]+$/)
    .min(5)
    .max(15),
  // チェックボックス（複数選択可）のバリデーション
  interest: zod.array(zod.string()).min(1),
  // チェックボックス（項目が1つだけ。値がtrue or false）のバリデーション
  // trueのみ許可
  confirm: zod.literal(true),
});

// バリデーションスキーマから型定義を作成
type userValues = zod.infer<typeof userSchema>;

// type UserValues = {
//   userId: string;
//   interest: string[];
//   confirm: true;
// }
```
</details>

# zodのエラーメッセージをカスタマイズする
`zod`のバリデーションエラーメッセージはデフォルトで英語になっています。
そのため、日本語で出力できるようにエラーメッセージをカスタマイズします。

## 方法1: 引数として渡す

一番簡単な方法です。
スキーマまたは各バリデーションメソッドの引数として渡します。

（このやり方は知っている！でもこのやり方は嫌なんだ！って方は`方法2`を参考にしてください。）

```ts:userSchema.ts
import * as zod from 'zod';

// オブジェクトスキーマ
const userSchema = zod.object({
  userId: zod
    // stringスキーマのエラーメッセージ
    .string({ required_error: '必須項目です', invalid_type_error: '入力値に誤りがります' })
    // 以下バリデーションメソッドの追加引数としてエラーメッセージを指定
    .regex(/^[a-zA-Z0-9]+$/, { message: '半角英数字で入力してください' })
    .min(5, { message: '5文字以上で入力してください。' })
    .max(15, { message: '15文字以下で入力してください。' }),
  interest: zod
    .array(zod.string({ required_error: '必須項目です', invalid_type_error: '入力値に誤りがります' }))
    .min(1, { message: '1つ以上選択してください' }),
  // literalは引数でmessageを渡せない
  confirm: zod.literal(true),
});
```
中には引数でメッセージを渡せないものもあります。
そのため、**全てのバリデーションメソッドでこの方法が利用できるわけではありません！**

また、個別にメッセージを指定するのは不便です:pensive:

## 方法2: ZodErrorMapでエラーメッセージをカスタマイズする

以下の様に`ZodErrorMap`を用いて、`zod`のエラーコードごとにメッセージをカスタマイズします。

```ts:customErrorMap.ts
import * as zod from 'zod';

/**
 * zodエラーメッセージ（日本語）
 */
const customErrorMap: zod.ZodErrorMap = (issue, ctx) => {
  // zodエラーコードごとにメッセージをカスタマイズする
  switch (issue.code) {
    // 型に誤り
    case zod.ZodIssueCode.invalid_type:
      // undefinedだった場合は未入力判定
      if (issue.received === zod.ZodParsedType.undefined) {
        return { message: '必須項目です' };
      } else {
        return { message: '値に誤りがあります' };
      }

    case zod.ZodIssueCode.too_big:
      return { message: `${issue.maximum}文字以内で入力してください` };

    case zod.ZodIssueCode.too_small:
      if (issue.type === 'array') {
        return { message: `${issue.minimum}つ以上チェックしてください` };
      }
      return { message: `${issue.minimum}文字以上で入力してください` };
  }

  // デフォルトのメッセージを返す
  return { message: ctx.defaultError };
};
```

上記条件を満たさないエラーの場合は、デフォルトのエラーメッセージを出力します。

独自の`ZodErrorMap`を作成する時は、`zod`の[デフォルトメッセージをセットしているファイル](https://github.com/colinhacks/zod/blob/master/src/locales/en.ts)が参考になります！

https://github.com/colinhacks/zod/blob/master/src/locales/en.ts

<details><summary>ZodErrorMap について</summary>

https://github.com/colinhacks/zod/blob/master/ERROR_HANDLING.md#customizing-errors-with-zoderrormap

- `{ message: string } `を返す必要がある
- `issue`はどういったエラーであったかというデータから`message`を除外したエラー詳細情報
- `ctx.default`はデフォルトのエラーマップによって生成されたエラーメッセージ
- `src/ZodError.ts`で定義された`defaultErrorMap`の優先順位が最も低く、上書きされたメッセージ（カスタマイズされたエラーメッセージ）の方が優先順位が高い

</details>

### カスタマイズした`ZodErrorMap`をグローバルに適用する

カスタムの`ZodErrorMap`をグローバルに適用するには`.setErrorMap()`に指定するだけです。

```ts
// zodカスタムエラーメッセージをグローバルに適用
zod.setErrorMap(customErrorMap);
```

`zod`を`import`しているファイルであるならば`.setErrorMap()`を呼び出せます。
ただ、**適用範囲がグローバルなので、どこで呼び出すのかはプロジェクトごとにルールを決めたほうが良い**でしょう。

### カスタマイズした`ZodErrorMap`を個別のスキーマにバインドする

グローバルに適用させるのはちょっと...という場合には、
カスタマイズした`ZodErrorMap`を個別のスキーマにバインドする（デフォルトエラーをカスタムエラーで上書きする）こともできます。

```diff_tsx:userSchema.ts
const userSchema = zod.object({
  userId: zod
+    .string({ errorMap: customErrorMap })
    .regex(/^[a-zA-Z0-9]+$/)
    .min(5)
    .max(15),
+  interest: zod.array(zod.string({ errorMap: customErrorMap })).min(1),
  confirm: zod.literal(true),
});
```
上記ではオプションの`errorMap`の値として、今回作成した`customErrorMap`を設定しています。

これで以下の様な出力になります。
- `userId`, `interest`：`customErrorMap`のエラーメッセージが出力される
- `confirm`：デフォルトのエラーメッセージが出力される

### 共通のメッセージはグローバルの`ZodErrorMap`から出力し、一部の項目は個別のスキーマにバインドした`ZodErrorMap`から出力する

これがよくあるパターンかなと思います。
`.max`などは共通で設定されているエラーメッセージから出力し、一部項目は個別のスキーマ単位で設定する方法です。

先程の`customErrorMap`はグローバルに適用するように設定します。
```ts
// zodカスタムエラーメッセージをグローバルに適用
zod.setErrorMap(customErrorMap);
```

この`userSchema`のエラー時だけ利用する、カスタムの`userSchemaErrorMap`を作成します。

```ts:userSchemaErrorMap.ts
import * as zod from 'zod';
import { customErrorMap } from '@/util/zod/customErrorMap';

/**
 * userSchema用カスタムエラーメッセージ
 */
const userSchemaErrorMap: zod.ZodErrorMap = (issue, ctx) => {
  switch (issue.code) {
    case zod.ZodIssueCode.invalid_literal:
      // true/falseチェックボックス用メッセージ
      return { message: 'チェック必須です' };

    case zod.ZodIssueCode.invalid_string:
      if (issue.validation === 'regex') {
        return { message: '半角英数字で入力してください。' };
      }
  }

  // グローバルErrorMapのメッセージを返す
  return { message: customErrorMap(issue, ctx).message };
};
```

#### グローバルのエラーマップとスキーマのエラーマップの違い

グローバルのエラーマップ（`customErrorMap`）と、
スキーマのエラーマップ（`userSchemaErrorMap`）の違いは、
いずれのエラーコードにも合致しないエラーが発生した場合のメッセージの指定です。

グローバルのエラーマップでは、`zod`のデフォルトメッセージを返しています。

```ts:customErrorMap（グローバル）の場合
// デフォルトのメッセージを返す
return { message: ctx.defaultError };
```

一方スキーマのエラーマップでは、グローバルの`customErrorMap`のメッセージを返しています。

```ts:userSchemaErrorMap（スキーマ）の場合
// グローバルErrorMapのメッセージを返す
return { message: customErrorMap(issue, ctx).message };
```

こう指定することで、
`スキーマのエラーマップ`から該当するエラーメッセージを探す
→　`グローバルのエラーマップ`から該当するエラーメッセージを探す
→　デフォルトのエラーメッセージを探す

となります。

これによって
**共通のメッセージは、グローバルの`ZodErrorMap`から出力し、
一部の項目は、個別のスキーマにバインドした`ZodErrorMap`から出力する**
が実現できます。

最後に、このスキーマのエラーマップ（`userSchemaErrorMap`）を適用したい項目に設定します。

```diff_tsx:userSchema.ts
const userSchema = zod.object({
  userId: zod
+    .string({ errorMap: userSchemaErrorMap })
    .regex(/^[a-zA-Z0-9]+$/)
    .min(5)
    .max(15),
  interest: zod.array(zod.string()).min(1),
+  confirm: zod.literal(true, { errorMap: userSchemaErrorMap }),
});
```

上記の場合、以下の様な出力になります。
- `.regex`, `.literal`のエラー：`userSchemaErrorMap`からエラーメッセージを表示する
- `.min`, `.max`のエラー：グローバルの`customErrorMap`からエラーメッセージを表示する


`ZodErrorMap`を活用して、良き`zod`ライフを！