Gemcook Tech Blog
💎

【zod】「なくてもいいけど、あるならちゃんと」な、バリデーション

2024/08/27に公開

こんばんは!
今回は、最近ドツボにハマってしまって時間をかけてしまった実装についての記事です。
紹介したいのはz.optional()でもなく、z.nullish()でもなく、z.nullable()でもなく、あくまでもタイトルにあるように 「なくてもいいけど、あるならちゃんと」 なバリデーションです...👼

「なくてもいいけど、あるならちゃんと」なバリデーション

さて...。「なくてもいいけど、あるならちゃんと」なバリデーション...何をいうとるんでしょうか?
これ以外の呼び名が思いつかず、こんな表現になってしまいましたが今回はこれで通させていただきます。(もし一般的な呼び名を知っている方がいれば、ぜひコメントに...🙇)

例えば、
一つはPCのアドレスが必須入力なTextInput、もう一つはスマホのアドレスを 任意 で登録できるTextInputがあるフォームを想像してみてみましょう。
この時の、「スマホのアドレスを任意で登録できるTextInput」にかけたくなるバリデーションを、「なくてもいいけど、あるならちゃんと」なバリデーションと呼んでいます。

こんなフォームを実現するためのvalidationをzodを使って実装してみましょう。

ん?.optional.nullish.nullableじゃだめなの?

最初にあげたz.optional()z.nullish()z.nullable()を使用して、想定しているようなバリデーションを実装してみましょう。

const damenaSchema = z.object({
    optional: z.string().email().optional(),
    nullish: z.string().email().nullish(),
    nullable: z.string().email().nullable(),
}) 

type DamenaSchema = z.infer<typeof damenaSchema>

// type DamenaSchema = {
//  optional?: string | undefined;   「email形式でもいいし、undefinedでもいい」
//  nullish: string | null;   「email形式でもいいし、nullでもいい」
//  nullable?: string | null | undefined;   「email形式でもいいし、undefinedでもnullでもいい」
// }

一見これらのうち、どれでもOKな気がしますが、実はいくつか問題があります。

問題1: controlled/uncontrolledコンポーネント

上記のいずれかで実装した場合、TextInputに値を入力するまでは大丈夫なのですが、入力した途端こんなWarningが出ます。

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component.

「『uncontrolledな<input/>』 から『controlledな<input/>』に変わってしまったよ。やめてね。」とReactに怒られてしまいます。
ざっくりいうと、「<input/>valueundefined(or null)を渡している状態から、stringを渡すように変更するのがダメだよ」ということです。(詳細は以下のReactの公式ドキュメントを参照してみてください🙇)

...これじゃあだめですね。

https://react.dev/reference/react-dom/components/input#im-getting-an-error-a-component-is-changing-an-uncontrolled-input-to-be-controlled

問題2: 入力 -> 削除でおかしくなる。

問題1のReactからのWarningを思い切って無視してみましょう!!!!
...それでも問題は潜んでいます。実は、そもそもやりたいことが実現できていません。

上記のうちのz.optional()の実装でvalidationをかけた場合、以下のような流れの挙動になります。

状態 valid/invalid 期待通り? 補足
1 未入力(undefined) valid undefined許容なので期待通り
2 入力途中 hoge_ho invalid まだemailを満たしていないので期待通り
3 入力完了 hoge_hoge@gmail.com valid emailを満たしたので期待通り
4 削除中 hoge_ho invalid emailを満たさなくなったので期待通り
5 削除完了(""になる) invalid 🚫 undefinedemailも満たしていないのでinvalidになっているが、ユーザー的には未入力なので、期待通りではない

onChangeによって値を更新しているため、入力していた値を削除すると、undefinedではなく、""になってしまいます。
schemaでは空文字は許容していません。emailorundefinedしか許容していないからですね。

...どのように実装すればいいのでしょうか。空文字が入力されたのをみてundefinedに置き換える?schemaの方でtransformなどを使用してコネコネする...?
絶対嫌ですね😇僕は嫌です😇

本題: なくてもいいけど、あるならちゃんと

では、「なくてもいいけど、あるならちゃんと」つまり、

  • なくてもいいけど...。
    • 入力がなければvalid
    • 入力を削除し、inputを空にもどせばもちろんvalid
  • あるならちゃんと...。
    • 1文字でも入力するなら、指定のvalidationを満たさないとinvalid

なvalidationを実装するにはどうすればいいでしょうか?結論から言うと実装例は以下のコードの通りです。

const niceSchema = z.object({
    optionalEmail: z.union([
        z.literal(""),
        z.string().email()
    ])
})

「空文字(z.literal(""))」と「email形式(z.string().email())」のどちらか(z.union([]))」という構造で、「なくてもいいけど、あるならちゃんとemail形式」なvalidationを実装することができました!

もちろんz.string().email()をお好みのものに置き換えれば、「なくてもいいけど、あるならちゃんと〇〇」なvalidationを実装することができます。

まとめ

というわけで、今回は「なくてもいいけど、あるならちゃんと」というバリデーションの実装を行いました。
さて、実装時は困って色々調べてこの方法にたどりついたのですが、この記事を書いた後に公式ドキュメントを散歩していると、そのまんま書いてありました😇

https://zod.dev/#:~:text=Optional string validation%3A

実装に詰まっている時はドキュメントからその記述を見つけられず、解決した途端ドキュメントがみつかる...。
僕以外にも同じく悩んだ人はいるだろうということで、せっかく書いたので供養として残しておきたいと思います。

ありがとうございました!

Gemcook Tech Blog
Gemcook Tech Blog

Discussion