💎

Zodのエラーメッセージの定義って面倒じゃない?

2022/12/23に公開

フォームを作成する場合、最近は個人的には react-hook-formzod を使用して実装することが多い。

この2つの組み合わせに関する解説は他の方の記事に譲る。

https://zenn.dev/uzimaru0000/articles/react-hook-form-with-zod

こんな感じで、簡単にスキーマの定義とバリデーションの設定ができて、フォームの各フィールドと組み合わせることが可能である。

const schema = z.object({
  username: z
    .string()
    .min(3, "3文字以上で入力してください")
    .max(10, "10文字以上で入力してください"),
  email: z.string().email("メールアドレスの形式で入力してください"),
  favoriteNumber: z
    .number()
    .max(10, "10以下の数字を入力してください")
    .min(1, "1以上の数字を入力してください"),
});

const Form = () => {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: zodResolver(schema),
  });
  
  return (
    <form onSubmit={handleSubmit(console.log)}>
      <FormControl isInvalid={!!errors.username} mb={4}>
        <FormLabel htmlFor="username">ユーザ名</FormLabel>
        <Input id="username" {...register("username")} />
        <FormErrorMessage>{(errors.username?.message ?? "")}</FormErrorMessage>
      </FormControl>
      {/* 省略 */}
    </form>
  )
}

zodのメッセージの定義は面倒

この上なく便利な組み合わせだが、唯一の不満はバリデーションメッセージを定義しないといけないところだ。
こんな感じで一つ一つの条件に対して、メッセージを割り当てる必要がある。
また「3文字」とか「10以下」とか、条件のパラメータをメッセージにベタで含めないといけないのも、若干もやっとするところ。

const schema = z.object({
  username: z
    .string()
    .min(3, "3文字以上で入力してください")
    .max(10, "10文字以上で入力してください"),
  email: z.string().email("メールアドレスの形式で入力してください"),
  favoriteNumber: z
    .number()
    .max(10, "10以下の数字を入力してください")
    .min(1, "1以上の数字を入力してください"),
});

この定義を省略する事もできるが、英語のデフォルトメッセージが表示されてしまうので、日本人に対してのサービス提供を前提とするのであれば、この手間を省くことはできない。

「英語であれば定義が省けるのになー...」と、自分が英語圏の人間でないことが若干悔しい気持ちになる。

また、実際のサービスではもっとフィールドの数が多かったり、サービスの中でいくつかのスキーマを管理しなければならないこともあるので、複雑である上に表記ゆれの問題も考慮しなければならなくなる。

Railsでサービスを作っていた頃はこういったエラーメッセージをi18nの辞書yamlで管理していた経験のせいか、こういったメッセージを直接コード内に書くのは個人的に好きではない。

なので、i18nの仕組みを使っていい感じにできないかと探してみたが、残念ながらzodのi18nプロジェクトがなかったので自分で作った。

zod + i18next

https://github.com/aiji42/zod-i18n

内部的には、zodのエラーマッピングを i18next で解釈可能なフラットなキーに変換して、翻訳ファイルからメッセージを引き当てるという簡単な仕組み。

https://www.i18next.com/

これを使用すると、エラーメッセージの定義を省くことができる。

import i18next from 'i18next'
import { z } from 'zod'
import { zodI18nMap } from "zod-i18n-map"
import translation from 'zod-i18n-map/locales/ja/zod.json'

i18next.init({
  lng: 'ja',
  resources: {
    ja: { zod: translation },
  },
});
z.setErrorMap(zodI18nMap);

const schema = z.object({
  username: z.string().min(3).max(10),
  email: z.string().email(),
  favoriteNumber: z.number().max(10).min(1),
});

const Form = () => {
  // 省略
}

実際のメッセージの定義はこんな感じでJSONで定義しておく。

https://github.com/aiji42/zod-i18n/blob/main/packages/core/locales/ja/zod.json#L1-L19

これはZodの全メッセージをカバーするためのものなので、フォームのバリデーションでは使用しないようなものも含まれている。
不要なメッセージを削ったり、もう少し会話調でフレンドリーなメッセージに変えてもよし。


実際に動いている様子はこんな感じ。
next-i18next などと併用すれば、フィールドのラベル名なども直書きしなくて済むので、表記ゆれ対策としてもよい。

https://zod-i18n.vercel.app/ja

ソース
import { useForm } from "react-hook-form";
import {
  FormErrorMessage,
  FormLabel,
  FormControl,
  Input,
  Button,
  Select,
  InputGroup,
  InputLeftAddon,
  Container,
  Flex,
  Heading,
  Spacer,
} from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { makeZodI18nMap } from "zod-i18n-map";
import { GetServerSideProps } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { useTranslation, Trans } from "next-i18next";
import { useRouter } from "next/router";
import { setCookie } from "nookies";
import { ChangeEventHandler, useCallback } from "react";

export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
  return {
    props: {
      ...(await serverSideTranslations(locale!, ["common", "zod"])),
    },
  };
};

const schema = z.object({
  username: z.string().min(5),
  email: z.string().email(),
  favoriteNumber: z.number().max(10).min(1),
});

export default function HookForm() {
  const { t } = useTranslation();
  z.setErrorMap(makeZodI18nMap({ t, handlePath: { ns: ["common", "zod"] } }));
  const router = useRouter();

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: zodResolver(schema),
  });

  const changeLocale = useCallback<ChangeEventHandler<HTMLSelectElement>>(
    (e) => {
      const locale = e.currentTarget.value;
      setCookie(null, "NEXT_LOCALE", locale);
      router.replace("/", "/", { locale }).then(() => {
        router.reload();
      });
    },
    [router.replace, router.reload]
  );

  return (
    <Container maxW="container.xl">
      <Flex as="header" py="4" justifyContent="space-between">
        <a href="https://github.com/aiji42/zod-i18n" rel="noopener noreferrer">
          <Heading as="h1" fontSize="xl" color="gray.600">
            zod-i18n-map
          </Heading>
        </a>
        <InputGroup maxW="3xs">
          <InputLeftAddon>🌐</InputLeftAddon>
          <Select
            defaultValue={router.locale}
            onChange={changeLocale}
            borderLeftRadius={0}
          >
            <option value="ar">العربية</option>
            <option value="es">Spanish</option>
            <option value="en">English</option>
            <option value="fr">Français</option>
            <option value="is">Icelandic</option>
            <option value="ja">日本語</option>
            <option value="pt">Português</option>
            <option value="zh-CN">简体中文</option>
          </Select>
        </InputGroup>
      </Flex>
      <form onSubmit={handleSubmit(console.log)}>
        <FormControl isInvalid={!!errors.username} mb={4}>
          <FormLabel htmlFor="username">
            <Trans>username</Trans>
          </FormLabel>
          <Input
            id="username"
            placeholder={t("username_placeholder") ?? undefined}
            {...register("username")}
          />
          <FormErrorMessage>
            {(errors.username?.message ?? "") as string}
          </FormErrorMessage>
        </FormControl>
        <FormControl isInvalid={!!errors.email} mb={4}>
          <FormLabel htmlFor="email">
            <Trans>email</Trans>
          </FormLabel>
          <Input
            id="email"
            placeholder="foo@example.com"
            {...register("email")}
          />
          <FormErrorMessage>
            {(errors.email?.message ?? "") as string}
          </FormErrorMessage>
        </FormControl>
        <FormControl isInvalid={!!errors.favoriteNumber} mb={4}>
          <FormLabel htmlFor="favoriteNumber">
            <Trans>favoriteNumber</Trans>
          </FormLabel>
          <Input
            id="favoriteNumber"
            {...register("favoriteNumber", { setValueAs: Number })}
          />
          <FormErrorMessage>
            {(errors.favoriteNumber?.message ?? "") as string}
          </FormErrorMessage>
        </FormControl>
        <Button
          mt={4}
          colorScheme="teal"
          isLoading={isSubmitting}
          type="submit"
        >
          <Trans>submit</Trans>
        </Button>
      </form>
    </Container>
  );
}

おまけ

zod 本体のドキュメントにリンクを載せるPRをおくったところ、ありがたいことにオーナーから「i18nについて別途話をしよう」と言われたので、zod本体に取り込んでもらってこのライブラリをクローズすることを来年の目標としたい。

GitHubで編集を提案

Discussion