Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Yup to Zod ~スキーマバリデーションライブラリの移行~

Contract One Dev グループの井上です。本年もどうぞよろしくお願い申し上げます。

私たちのチームでは、先日、「Contract One Cheat Day」と銘打って社内ハッカソンを開催しました。
社内ハッカソンの内容については、社内ハッカソンを開催したら、止まっていた改善が色々進んだ話: Contract One Cheat Day - Sansan Tech Blogをご覧ください。
本記事では、そんな社内ハッカソンにおける取り組みの一つ、”Zod導入”について書いていきます。

Zodの導入について

Yupでいいんじゃない?

Zodとは、TypeScriptファーストのスキーマバリデーションライブラリです。類似のライブラリとしては、以前から広く使われているYupが挙げられます。Contract Oneでも、これまでYupを採用してきました。

Zodは比較的新しいライブラリで、Yupと比べると歴史は浅いものの、その人気は急速に伸びています。実際、yup | npm trendsによると、ここ2年ほどでZodのダウンロード数が大きく増加しています。

とはいえ、新しいものが常に良いとは限りません。「本当にZodに切り替える必要があるのか?」「むしろYupのままの方が良いのでは?」という疑問は自然に湧いてきます。
そこでここからは、私たちがZodを導入するに至った理由について書いていきます。

簡単なフォームなら…?

YupとZodの違いをまずは、簡単なユーザーの登録フォームのバリデーションを例に考えてみます。まず、Yupの場合、次のようになります。

import * as yup from "yup"

const validationSchema = yup.object({
  name: yup.string().required(),
  age: yup.number().required(),
});

Zodで書くと、次のようになります。

import z from "zod"

const validationSchema = z.object({
   name: z.string(),
   age: z.number(),
});

これだけだと、「YupでもZodでも一緒じゃね?」という声が聞こえてきそうです。特筆すべきところがあるとすれば、Zodの場合はデフォルトでrequiredなので、requiredをつける必要がないというところくらいでしょうか。これくらいの違いしかないなら、わざわざZodにしなくても良さそうです。

複雑なフォームなら…?

では、もっと複雑なフォームで考えてみます。仮に、支払い方法としてPayPayかクレジットカードを選択できるフォームのバリデーションを考えてみます。PayPayを選んだ場合にはPayPayのアカウントIDが、クレジットカードを選んだ場合にはクレジットカードナンバーの入力が必要だとします。この場合はどうでしょうか、まずはYupから。

import * as yup from "yup"

const validationSchema = yup.object({
  paymentMethod: yup.string().oneOf(["paypay", "creditCard"]),
  accountId: yup.string().when("paymentMethod", {
    is: "paypay",
    then: yup.string().required(),
    otherwise: yup.string().optional(),
  }),
  cardNumber: yup.string().when("paymentMethod", {
    is: "creditCard",
    then: yup
      .string()
      .matches(/^\d{16}$/)
      .required(),
    otherwise: yup.string().optional(),
  }),
})

続いてZodです。

import { z } from 'zod';

const paypaySchema = z.object({
  paymentMethod: z.literal('paypay'),
  accountId: z.string(),
});

const creditCardSchema = z.object({
  paymentMethod: z.literal('creditCard'),
  cardNumber: z.string().regex(/^\d{16}$/),
});

const validationSchema = z.discriminatedUnion('paymentMethod', [
  paypaySchema,
  creditCardSchema,
]);

両者を比較すると、Zodの方が条件分岐の書き方がシンプルに見えます。しかし、より重要なのは型推論の違いです。次項はそれについて見ていきます。

型推論の違い

TypeScriptを用いた開発においては、フォームの値に型をつけることがほぼ必須になってきます。バリデーションスキーマからフォームの型を推論できたら楽ですが、Yupの場合はどうでしょうか。

Yupには、InferTypeというメソッドが用意されています。しかし、InferTypeの型推論能力はあまり高くありません。今回の場合も、判別可能なunion型を表現したければ、フォームの型は次のように自前で書く必要があります。

type FormValue =
  | {
      paymentMethod: "paypay"
      accountId: string
    }
  | {
      paymentMethod: "creditCard"
      cardNumber: string
    }

しかし、自前で型を書くことには望ましくない面もあります。それは、バリデーションスキーマとの整合性を担保するのが難しいことです。型とバリデーションスキーマがちゃんと一致しているかどうかを気にかけながら開発しなければなりません。これはかなり手間ですし、ミスをするとバグにもなりえます。

一例を挙げると、追加のクレジットカード情報として、名義人の氏名や有効期限日が必要となったらどうでしょうか。型だけ変えてバリデーションの変更を失念するかもしれないし、逆にバリデーションを変えて型の方を変え忘れることもあるかもしれません。有効期限日の型はDate型なのにバリデーションはstring()を使っていた、なんてこともありそうです。

では、Zodの場合はどうでしょうか。Zodは、TypeScriptファーストのスキーマバリデーションライブラリです。その点、どんなに複雑なフォームであっても、次のように問題なく型の推論が効きます。

const validationSchema = z.discriminatedUnion('paymentMethod', [paypaySchema, creditCardSchema]);
type FormValue = z.infer<typeof validationSchema> // 自前で書いたのと同じ型になる

Zodの場合はこのような書き方ができるので、フォームに変更があった場合でも、「型とバリデーションの両方に整合性のある変更を慎重に加える」という必要はありません。単にバリデーションだけに変更を加えれば十分です。

このように、「バリデーションからフォームの型を自在に推論できる」というのは意外に大きいメリットなのです。

まとめ

本記事では、Zodを導入した理由を、YupとZodを比較しながらまとめていきました。いろいろ書きましたが、ポイントは一つだけで、ZodはTypeScriptファーストのスキーマバリデーションライブラリです。TypeScriptを使った環境で複雑なフォームの要件がある場合には、Zodの方が適切であると考えます。

私たちのケースにおいては、

  • 複雑なバリデーションを必要とする箇所が多いこと
  • それまでのYupの運用では、型とバリデーションの不一致により意図しないエラー文言が表示されてしまうなど、限界があったこと
  • 新しいライブラリの学習コストは、型とバリデーションが一致するメリットに比して、許容できるものであること

などが最終的な判断の決め手になりました。

最後になりましたが、私たちが開発しているプロダクトの、契約データベース「Contract One」はフェーズ的にも脂の乗った美味しい時期です。冬の鰤のようなものだといっても過言ではありません。そして、開発メンバーもウルトラ募集中です。少しでも興味を持たれた方はぜひカジュアル面談でお話ししましょう。

media.sansan-engineering.com

最後までお読みいただきありがとうございました!!

© Sansan, Inc.