📖

YupでZodのようなスキーマファーストなバリデーションを目指す

2023/08/28に公開

はじめに

この記事は毎週必ず記事がでるテックブログ "Loglass Tech Blog Sprint"2週目 の記事です!
1年間連続達成まで 残り51週 となりました!

最近Zodすごいな〜ログラス(弊社)はYup使っているな〜Yupだとスキーマファーストなバリデーションできないよなーと思ったらできたので書きます。

誰向け?

yupは歴史が長いこともあり、ZodではなくYupをバリデーションライブラリに使っているプロジェクトは多いと思います。
だいたいのWebアプリケーションにはフォームが存在し、多くのフォームにはバリデーションが存在します。なのでWebアプリケーションのバリデーションライブラリの依存度は非常に高く、おそらくリプレイスするのは骨が折れるでしょう。
といいつつ、冷静にみてみると既存のバリデーションをもっとタイプセーフにしたい、スキーマファーストに実装したいレベルの話であるならばYupで十分対応可能です。
Yupでスキーマファーストなバリデーションができることはあまり知られていないので記事を書いていきます。
今回はログラスで使っている React Hook Form + Yup(1.x系)の構成のバリデーションについて書いていきます。

Zod + React Hook Formでこういうのがしたかった

import z from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';

const sampleSchema = z.object({
    input: z
      .string()
      .max(30)
})

type SampleForm = z.infer<typeof sampleScheme>

const Hoge = () => {
  const { register } = useForm<SampleForm>({
    defaultValues: {
      input2: 'a', // フィールド名が違うのでコンパイルエラー
      input: 3, // 型が違うのでコンパイルエラー
    },
    resolver: zodResolver(sampleSchema)
  })
  
  return <>中略</>
}

こんな感じでバリデーション用のスキーマから型を生成し、スキーマと型情報をReact Hook Formに渡してタイプセーフなフォームを作る。とてもいいですね。美しい。

それ Yup でもできます。

厳密にはYupの1.x系以降のバージョンならできます。さあみんなバージョンアップするんだ!

Yup + React Hook Formでやってみる

package.json

{
  "dependencies": {
    "yup": "1.1.1",
    "react-hook-form": "7.44.3",
    "@hookform/resolvers": "3.1.0",
  }
}
import yup from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import { useForm } from 'react-hook-form';

const sampleSchema = yup.object({
    input: yup
      .string()
      .max(30)
})

type SampleForm = yup.InferType<typeof sampleScheme>

const Hoge = () => {
  const { register } = useForm<SampleForm>({
    defaultValues: {
      input2: 'a', // フィールド名が違うのでコンパイルエラー
      input: 3, // 型が違うのでコンパイルエラー
    },
    resolver: yupResolver(sampleSchema)
  })
  
  return <>中略</>
}

できた!やった!!

Yupでは InferType を使うことでスキーマから型情報を受け取ることができます。

公式ドキュメントはこちらです。

GitHub - jquense/yup: Dead simple Object schema validation

おそらく基本的なスキーマ→型生成→フォームへの組み込みの流れならyupでも十分事足りるような気がします。

zodが活きるのは他ライブラリでそのままスキーマを使うだったり、全ての型をスキーマで定義するだったり、バリデーション以上の箇所で使うことでしょう。

https://zenn.dev/aiya000/articles/cd06a0f3620d59

カスタムバリデーションでもスキーマファーストできるようにする

https://zenn.dev/yuitosato/articles/292f13816993ef#カスタムバリデーション

こちらの記事で言及しましたが、ログラスではyupのaddMethodを使ってカスタムメソッドをスキーマで使えるようにしています。

const validationSchema = validator.object().shape({
  input: validator
    .string()
    // ログラス独自のカスタムバリデーション
    .noEmpty({
      label: 'インプット',
    })
    // ログラス独自のカスタムバリデーション
    .maxLength({
      label: 'インプット',
      max: 30,
    }),
  radiobutton: validator.string<'radioA' | 'radioB'>().noEmpty({
    label: 'ラジオボタン',
  }),
});

type SampleForm = InferType<typeof validationSchema>;

const SampleFormModal = ({ closeModal }: SampleFormModalProps) => {
  const { control, handleSubmit } = useDefaultForm<SampleForm>({
    defaultValues: {
      input: '',
      radiobutton: 'radioA',
    },
    resolver: yupResolver(validationSchema),
  });
    
  return <>省略</>
}

このように必須項目や、文字数制限などの基本的なバリデーションも外からラベル名を取れたり、フォームの中身の値を使って動的にエラーメッセージを作れるように全てカスタムバリデーションで実装しています。
(エラーメッセージを各言語に対応するときはこれだと少し難しいかもしれないが、ログラスは現状は日本語のみです。)

addMethodの実装としては以下のような形で実装できます。
ここでは上述のmaxLengthのカスタムバリデーションの実装を一例として紹介していきます。

// バリデーターを定義。こちらは純粋な関数なのでテストが容易に書ける
const maxLengthValidator = (
  value: unknown,
  label: string,
  max: number,
  { path, createError }: ValidationContext
): ValidateResult => {
  if (typeof value !== 'string') return true;
  const isValid = value.length <= max;
  return isValid
    ? true
    : createError({
        path,
        message: `${label}${max}文字以下で入力してください(${
          value.length - max
        }文字超過しています)`,
      });
};

// yupに maxLength というバリデーションが追加される。
yup.addMethod(yup.string, 'maxLength', function ({ label, max }: { label: string; max: number }) {
  return this.test('maxLength', '', function (value: unknown) {
    return maxLengthValidator(value, label, max, this);
  });
});

// 最後にexport
export const validator = yup;

あとは使うときに明示的に最後にexportしているvalidatorを使います(これは評価順によってはYupでもいいのかもしれない)

import { validator } from '~/common/utils/validator';

validator
  .string()
  .maxLength({
    label: 'インプット',
    max: 30,
  }),

YupのaddMethodの使い方は以下の記事が参考になります。
https://medium.com/@iamarkadyt/how-does-yup-addmethod-work-creating-custom-validation-functions-with-yup-8fddb71a5470

型定義に関しては yup.d.tsを用意してYupの型を拡張します。

export {};

declare module 'yup' {
  interface StringSchema<TType, TContext, TDefault, TFlags> {
    maxLength({
      label: string,
      max: number,
    }): StringSchema<NonNullable<TType>, TContext, TDefault, TFlags>;
  }
}

詰まったところとしてはジェネリクスをちゃんと同じ個数指定しきらないと型の上書きができなかったことです。

実際にYup本体のコードのindex.d.tsでも以下のように4つのジェネリクスが指定されているのでその通り指定して型を定義する必要があります。

https://github.com/jquense/yup/blob/41b9c582575b351123fe51adb6a377dbaad26623/src/string.ts#L53-L58

Zodでは

ちなみにZodではこの辺は refinesuperRefine をつかうのみでカスタムメソッドのようなものを生やすことはできなさそうです。(間違えていたらすいません。)
メソッドを生やしてサジェストがでやすくメソッドチェーンで開発できるとちょっとは良いのかもしれません。もちろんこれは絶対的に良いというレベルのものでもなく現場の思想によるものだと思います。

おわりに

最近のZodの盛り上がりにいいな〜と思うYup使いの方はいるかもしれないですが、実は置き換えなんかせずともYupでできるところも多いよという話でした。
ありがとうございました!

株式会社ログラス テックブログ

Discussion