Zodのエラーメッセージの定義って面倒じゃない?
フォームを作成する場合、最近は個人的には react-hook-form
と zod
を使用して実装することが多い。
この2つの組み合わせに関する解説は他の方の記事に譲る。
こんな感じで、簡単にスキーマの定義とバリデーションの設定ができて、フォームの各フィールドと組み合わせることが可能である。
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
内部的には、zodのエラーマッピングを i18next で解釈可能なフラットなキーに変換して、翻訳ファイルからメッセージを引き当てるという簡単な仕組み。
これを使用すると、エラーメッセージの定義を省くことができる。
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で定義しておく。
これはZodの全メッセージをカバーするためのものなので、フォームのバリデーションでは使用しないようなものも含まれている。
不要なメッセージを削ったり、もう少し会話調でフレンドリーなメッセージに変えてもよし。
実際に動いている様子はこんな感じ。
next-i18next
などと併用すれば、フィールドのラベル名なども直書きしなくて済むので、表記ゆれ対策としてもよい。
ソース
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本体に取り込んでもらってこのライブラリをクローズすることを来年の目標としたい。
Discussion