【2022年】 React Hook FormでValidationライブラリはどれにするか?


React + Typescript + MUIv5 + React Hook Form で開発環境を作成しています。
今回は React Hook FormでValidationライブラリをどれにするか? について調査しました。

React Hook Form が標準で対応しているライブラリ

React Hook Form が標準で対応しているライブラリは以下の10個になります。
正確には React Hook Form で 外部の検証ライブラリを使用する為に @hookform/resolvers が必要なのですが、この @hookform/resolvers が標準対応しているライブラリが以下の10個になります。
その他のライブラリもカスタムリゾルバを構築して使用できます。

■ npm人気順

npm の人気順です。

  1. joi
  2. yup
  3. class-validator
  4. io-ts
  5. zod
  6. superstruct
  7. typanion
  8. vest
  9. computed-types
  10. nope

■ GitStarの順

GitStarの順です。

  1. joi(18,441)
  2. yup(16,342)
  3. class-validator(6,809)
  4. zod(6,705)
  5. superstruct(5,7488)
  6. io-ts(4,836)
  7. vest(1,816)
  8. computed-types(271)
  9. typanion(159)

■ 作成年度順

2020年:zod, typanion, computed-types
2018年:superstruct
2017年:io-ts
2016年:class-validato
2015年:vest, yup
2012年: joi
2011年:nope

■ ファイルサイズの小さい順

  1. nope(1.2KB)
  2. superstruct(3.4KB)
  3. computed-types(3.9KB)
  4. typanion(4.4KB)
  5. io-ts(5.2KB)
  6. vest(7.8KB)
  7. zod(10.4KB)
  8. yup(18.2KB)
  9. joi(42.0KB)
  10. class-validator(55.6KB)

GitStar上位の6つから選定する

たくさんあるので、GitStart順で上位の6つから選定することにします。

  1. joi(18,441)
  2. yup(16,342)
  3. class-validator(6,809)
  4. zod(6,705)
  5. superstruct(5,7488)
  6. io-ts(4,836)

■ joi

https://github.com/sideway/joi
2012年9月に作成されたライプラリです。
npmのダウンロード数、GitStarの数でも最も人気です。
ファイルサイズは42.0KBと大きいです。
サーバーサイドスクリプトを念頭に置いて設計されていて、フロントサイドではjoi-browserを使用します。
スキーマからTypeScriptの型を自動的に作成することができません。
フロントサイドだけで使用するなら、他のライブラリを使用した方がよいでしょう。

■ class-validator

https://github.com/typestack/class-validator
ブラウザーとnode.jsプラットフォームの両方で機能します。
ファイルサイズは55.6KBと大きいです。
フロントサイドだけで使用するなら、他のライブラリを使用した方がよいでしょう。

■ io-ts

https://github.com/gcanti/io-ts
2017年に作成されたライブラリでTypescript用にゼロから設計されています。
ファイルサイズが5.2KBと小さいです。
io-tsは関数型プログラミングライブラリ「fp-ts」の作成者が作成したライブラリです。
fp-ts と io-ts は依存関係にあり、io-ts を使用するには fp-ts と関数型プログラミングの概念をある程度理解していないと使いこなせないようです。

■ yup

https://github.com/jquense/yup
joiについで人気のあるライブラリです。
ファイルサイズは18.2KBです。
joiに大きく影響を受けていて、豊富なAPIがあります。
スキーマからTypeScriptの型を自動的に作成できます。

const schema = yup.object({
   name: yup.string()
         .required('名前を入力してください。')
         .min(4, '4文字以上で入力してください。'),
})

コードは直観的でとても書きやすいです。
日本語情報も豊富で、迷っても解決策がすぐ見つかります。

デメリットとして、yup はスキーマからの型推論が zod に比べ弱いです。
また、yupはデフォルトですべてのオブジェクトの値をoptionalとしているため、スキーマ定義が冗長になります。

例えば、yupはデフォルトですべてのオブジェクトの値をoptionalとしているため、スキーマname: yup.string()から推論される型はname: string | undefinedになります。
(zodの場合はname?: string | undefinedと省略可能プロパティになります)

// スキーマ
const schema = yup.object({
  name: yup.string() 
})
// 型推論で name: string | undefined になる
type Inputs = yup.InferType<typeof schema>

undefinedを許可しない場合はスキーマにrequired()を指定します。
スキーマname: yup.string().required()から推論される型はname: stringになります。

// スキーマ
const schema = yup.object({
  name: yup.string().required()
})
// 型推論で name: stringになる
type Inputs = yup.InferType<typeof schema>

少し前の記事では、型推論に不具合がありオブジェクトのoptionalな値から生成された型がrequiredになってしまうということがあったようです。
つまり、スキーマname: yup.string()はデフォルトでoptionalなので、推論される型がstring|undefinedになるべきところstringになっているという不具合があったようです。
現在はスキーマname: yup.string()の型推論はstring|undefinedとなります。
ただし、nameプロパティは省略可能なプロパティにはなりません。

配列のスキーマで型推論をする例です。
スキーマyup.array().of(yup.boolean())からの型推論は(boolean | undefined)[] | undefinedになります。

// スキーマ
const schema = yup.object({
  checks: yup.array().of(yup.boolean())
})
// 型推論で  checks: (boolean | undefined)[] | undefined になる
type Inputs = yup.InferType<typeof schema>

boolean[]を推論させるスキーマは
checks: yup.array().of(yup.boolean().required()).required()となり冗長になります。
(後記のzodは非常にシンプルに書けます)

// スキーマ
const schema = yup.object({
  checks: yup.array().of(yup.boolean().required()).required()
})
// 型推論で  checks: (boolean | undefined)[] | undefined になる
type Inputs = yup.InferType<typeof schema>

また、yup ではstring | number といった ユニオン型や、インターセクション型が表現できません。(zodでは表現できます)

React(ts) + ReactHookForm + MUIv5 TextField + yup のサンプルコード

インストール

npm install @hookform/resolvers
npm install yup

React(ts) + ReactHookForm + MUIv5 TextField のサンプルコード

import { Stack, TextField, Button } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'

+ import { yupResolver } from '@hookform/resolvers/yup'
+ import * as yup from "yup"

// yupを使用しない場合
- // type Inputs = {
- //  name: string
- // }

- // const validationRules = {
- //   name: {
- //     required: '名前を入力してください。',
- //     minLength: { value: 4, message: '4文字以上で入力してください。' }
- //   }
- // }

// yupを使用する場合
+ const schema = yup.object({
+   name: yup.string()
+         .required('名前を入力してください。')
+         .min(4, '4文字以上で入力してください。'),
+ })

+ type Inputs = yup.InferType<typeof schema>



export function InputReactHookFormTextField() {
  const {
    control,
    handleSubmit
  } = useForm<Inputs>({
    defaultValues: { name: 'piyoko' },
+     resolver: yupResolver(schema) // yupを使用する場合は指定する
  })

  
  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: ${data.name} typeof: ${typeof data.name}`)
  }

  return (
    <Stack component="form" noValidate 
    onSubmit={handleSubmit(onSubmit)} 
    spacing={2} sx={{ m: 2, width: '25ch' }}>

      <Controller
        name="name"
        control={control}
-        // rules={validationRules.name} // yup を使用しない場合は必要
        render={({ field, fieldState }) => (
          <TextField
            {...field}
            type="text"
            label="名前"
            error={fieldState.invalid}  
            helperText={fieldState.error?.message}
          />
        )}
      />
      <Button variant="contained" type="submit" >
        送信する
      </Button>
    </Stack>
  )
}

■ zod

https://github.com/colinhacks/zod
2020年に作成されたライブラリです。
ファイルサイズは10.4KBです。
Blitzにも使われている注目のライブラリです。
(Blitzは、Reactのライブラリの選定といった面倒なことを全部引き受けてくれるフルスタックフレームワークです)
最も後発のライブラリということもあり、他のライブラリの良い点・悪い点を参考にしているので優位です。

Zodでは値を「検証」ではなく「パース」しています。
パースに成功するとパース後の値を返し、失敗した場合はエラーをスローします。
スキーマからTypeScriptの型を自動的に作成でき、その型推論は正確です。

Zodはデフォルトですべての検証が必要と想定されていて、不要な場合は.optional()を指定します。
つまり、スキーマname: yup.string()から推論される型はname: stringになります。
.optional()を指定したスキーマname: yup.string().optional()から推論される型はname?: string | undefinedになり省略可能なプロパティとなります。

// スキーマ
const schema = z.object({
  firstname: z.string(),
  lastname: z.string().optional()
})
// 型推論で firstname: stringに、lastname?:string | undefined になる
type Inputs = z.infer<typeof schema>

また、zodではstring | number といった ユニオン型や、インターセクション型が表現できます。
たとえば、スキーマname: z.union([z.string(), z.number()])から推論される方はユニオン型のname: string | numberとなります。
型推論については yup より優れているといえます。

// スキーマ
const schema = z.object({
  name: z.union([z.string(), z.number()])
})
// 型推論で name:string | number になる
type Inputs = z.infer<typeof schema>

配列のスキーマで型推論をする例です。
スキーマz.array()(z.boolean())からの型推論はboolean[]になります。
yupに比べシンプルに書けます。

// スキーマ
const schema = z.object({
  checks: z.array(z.boolean())
})
// 型推論で  checks: boolean[] になる
type Inputs = z.infer<typeof schema>
React(ts) + ReactHookForm + MUIv5 TextField + zod のサンプルコード

インストール

npm install @hookform/resolvers
npm install zod

React(ts) + ReactHookForm + MUIv5 TextField のサンプルコード

import { Stack, TextField, Button } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'

+ import { zodResolver } from '@hookform/resolvers/zod'
+ import { z } from "zod"

// zodを使用しない場合
- // type Inputs = {
- //   name: string
- // }

- // const validationRules = {
- //   name: {
- //     required: '名前を入力してください。',
- //     minLength: { value: 4, message: '4文字以上で入力してください。' }
- //   }
- // }

// zodを使用する場合
+ const schema = z.object({
+   name: z.string()
+         .nonempty('名前を入力してください。-zod')
+         .min(4, '4文字以上で入力してください。-zod')
+ })

+ type Inputs = z.infer<typeof schema>


export function InputReactHookFormTextField() {
  const {
    control,
    handleSubmit
  } = useForm<Inputs>({
    defaultValues: { name: 'piyoko' },
+     resolver: zodResolver(schema)     // zodを使用する場合は指定する
  })



  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: ${data.name}`)
  }

  return (
    <Stack component="form" noValidate 
    onSubmit={handleSubmit(onSubmit)} 
    spacing={2} sx={{ m: 2, width: '25ch' }}>

      {/* コントローラー */}
      <Controller
        name="name"
        control={control}
-        // rules={validationRules.name}   // zod を使用しない場合は必要
        render={({ field, fieldState }) => (
          <TextField
            {...field}
            type="text"
            label="名前"
            error={fieldState.invalid}  
            helperText={fieldState.error?.message}
          />
        )}
      />
      <Button variant="contained" type="submit" >
        送信する
      </Button>
    </Stack>
  )
}

■ superstruct

https://github.com/ianstormtaylor/superstruct
2017年11月に作成されたライブラリです。
ファイルサイズが3.4KBと小さいです。
依存関係はなく、バックエンドとフロントエンドの両方で使用できます。
日本語情報はかなり少ないです。
書き方はチェーンではなく、関数型でネストするように書きます。
スキーマからTypeScriptの型を自動的に作成できます。

superstructは構造体でデータを定義します。
以下のようなスキーマを定義すると、nameは ss.Struct<string, null>といった構造体になります。

const schema = ss.type({
  name: ss.string()
})

superstructはデフォルトですべての検証が必要と想定されています。
つまり、スキーマname: yup.string()から推論される型はname: stringになります。

// スキーマ
const schema = ss.type({
  name: ss.string()
})
// 型推論 name:string
type Inputs = ss.Infer<typeof schema>

値がoptionalな場合は、下記のコードのように構造体をoptional()でラップします。
推論される型はname?: string | undefinedになり省略可能なプロパティとなります。

// スキーマ
const schema = ss.type({
  name: ss.optional(ss.string())
})
// 型推論 name?: string | undefined
type Inputs = ss.Infer<typeof schema>

name構造体の値が、optional かつ 文字数が4文字以上とする場合、下記のように構造体を size() でラップします。

const schema = ss.type({
  name: ss.optional(ss.size(ss.string(), 4))
})

さらに name はアルファベットのみとする場合は、さらに構造体をpattern()でラップします。

const schema = ss.type({
  name: ss.optional(ss.pattern(ss.size(ss.string(), 4), /[a-zA-Z]/))
})

さらに検証が続くと・・・ラップしてラップして・・・superstruct の書き方は好みが分かれるのではないでしょうか。

yup か zod か どちらにするか?

個人的な好みで、最終的に yup と zod が選択肢に残りました。
バックエンドでも検証を使用するなら zod に利点がありそうです。
フロントエンドだけの使用の場合だとどうでしょうか。

以前、コチラの記事でReactHookFormの機能だけで Validation を実装しました。
MUI v5 + React Hook Form v7 で、よく使うコンポーネント達を連携してみる
この記事で実装した Validation を yup と zod で書き比べてみました。

yup と zod で書き比べしたコンポーネントは、下記の5種になります。

  • TextField
  • Select
  • RadioGroup
  • DatePicker
  • 複数のチェックボックス

■ yup vs zod サンプルコード

React(ts) + ReactHookForm + MUIv5 TextField
yup
import { Stack, TextField, Button } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'

+ import { yupResolver } from '@hookform/resolvers/yup'
+ import * as yup from "yup"

// yupを使用しない場合
- // type Inputs = {
- //  name: string
- // }

- // const validationRules = {
- //   name: {
- //     required: '名前を入力してください。',
- //     minLength: { value: 4, message: '4文字以上で入力してください。' }
- //   }
- // }

// yupを使用する場合
+ const schema = yup.object({
+   name: yup.string()
+         .required('名前を入力してください。')
+         .min(4, '4文字以上で入力してください。'),
+ })

type Inputs = yup.InferType<typeof schema>


export function InputReactHookFormTextField() {
  const {
    control,
    handleSubmit
  } = useForm<Inputs>({
    defaultValues: { name: 'piyoko' },
+     resolver: yupResolver(schema) // yupを使用する場合は指定する
  })

  
  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: ${data.name} typeof: ${typeof data.name}`)
  }

  return (
    <Stack component="form" noValidate 
    onSubmit={handleSubmit(onSubmit)} 
    spacing={2} sx={{ m: 2, width: '25ch' }}>

      <Controller
        name="name"
        control={control}
-        // rules={validationRules.name} // yup を使用しない場合は必要
        render={({ field, fieldState }) => (
          <TextField
            {...field}
            type="text"
            label="名前"
            error={fieldState.invalid}  
            helperText={fieldState.error?.message}
          />
        )}
      />
      <Button variant="contained" type="submit" >
        送信する
      </Button>
    </Stack>
  )
}
zod
import { Stack, TextField, Button } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'

+ import { zodResolver } from '@hookform/resolvers/zod'
+ import { z } from "zod"

// zodを使用しない場合
- // type Inputs = {
- //   name: string
- // }

- // const validationRules = {
- //   name: {
- //     required: '名前を入力してください。',
- //     minLength: { value: 4, message: '4文字以上で入力してください。' }
- //   }
- // }

// zodを使用する場合
+ const schema = z.object({
+   name: z.string()
+         .nonempty('名前を入力してください。')
+         .min(4, '4文字以上で入力してください。')
+ })

+ type Inputs = z.infer<typeof schema>


export function InputReactHookFormTextField() {
  const {
    control,
    handleSubmit
  } = useForm<Inputs>({
    defaultValues: { name: 'piyoko' },
+     resolver: zodResolver(schema)     // zodを使用する場合は指定する
  })



  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: ${data.name}`)
  }

  return (
    <Stack component="form" noValidate 
    onSubmit={handleSubmit(onSubmit)} 
    spacing={2} sx={{ m: 2, width: '25ch' }}>

      {/* コントローラー */}
      <Controller
        name="name"
        control={control}
-        // rules={validationRules.name}   // zod を使用しない場合は必要
        render={({ field, fieldState }) => (
          <TextField
            {...field}
            type="text"
            label="名前"
            error={fieldState.invalid}  
            helperText={fieldState.error?.message}
          />
        )}
      />
      <Button variant="contained" type="submit" >
        送信する
      </Button>
    </Stack>
  )
}
React(ts) + ReactHookForm + MUIv5 Select
yup
import { Stack, FormControl, InputLabel, Select, MenuItem, FormHelperText, Button } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'
+ import { yupResolver } from '@hookform/resolvers/yup'
+ import * as yup from "yup"

- // type Inputs = {
- //   area: number | ''
- // }

- // const validationRules = {
- //   area: {
- //     validate: (value:number | '') => value !== '' || 'いずれかを選択してください。'
- //   }
- // }

+ const schema = yup.object({
+   area: yup.number()
+     .transform((value, originalvalue) => originalvalue === '' ? undefined : value)
+     .required('いずれかを選択してください。')
+ })

+ type Inputs = yup.InferType<typeof schema>

export function InputReactHookFormSelect() {

  const {
    control,
    handleSubmit
  } = useForm<Inputs>({
    defaultValues: { area: 6 },
+    resolver: yupResolver(schema)
  })
  

  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: ${data.area} typeof: ${typeof data.area}`)
  }

  return (
    <Stack component="form" noValidate 
      onSubmit={handleSubmit(onSubmit)} 
      spacing={2} 
      sx={{ m: 2, width: '25ch' }}>

      <Controller
        name="area"
        control={control}
-        // rules={validationRules.area}
        render={({ field, fieldState }) => (
          <FormControl fullWidth error={fieldState.invalid}>
            <InputLabel id="area-label">地域</InputLabel>
            <Select
              labelId="area-label"
              label="  " // フォーカスを外した時のラベルの部分こ(これを指定しないとラベルとコントロール線が被る)
              {...field}
            >
              <MenuItem value='' sx={{color:'gray'}}>未選択</MenuItem>
              <MenuItem value={1}>北海道</MenuItem>
              <MenuItem value={2}>東北</MenuItem>
              <MenuItem value={4}>関東</MenuItem>
              <MenuItem value={5}>中部</MenuItem>
              <MenuItem value={6}>近畿</MenuItem>
              <MenuItem value={7}>中国</MenuItem>
              <MenuItem value={8}>四国</MenuItem>
              <MenuItem value={9}>九州沖縄</MenuItem>
            </Select>
            <FormHelperText>{fieldState.error?.message}</FormHelperText>
          </FormControl>
        )}
      />
      <Button variant="contained" type="submit" >
        送信する
      </Button>
    </Stack>
  )
}
zod
/* eslint-disable react/jsx-props-no-spreading */
import { Stack, FormControl, InputLabel, Select, MenuItem, FormHelperText, Button } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'
+ import { zodResolver } from '@hookform/resolvers/zod'
+ import { z } from "zod"

- // type Inputs = {
- //   area: number | ''
- // }

- // const validationRules = {
- //   area: {
- //     validate: (value:number | '') => value !== '' || 'いずれかを選択してください。'
- //   }
- // }

+ const schema = z.object({
+   area: z.union([z.string(), z.number()])
+        .refine(
+         (val) => val !== '', { message: 'いずれかを選択してください。' }
+        )
+ })

+ type Inputs = z.infer<typeof schema>

export function InputReactHookFormSelect() {

  const {
    control,
    handleSubmit
  } = useForm<Inputs>({
    defaultValues: { area: 6 },
+     resolver: zodResolver(schema)
  })
  

  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: ${data.area}`)
  }

  return (
    <Stack component="form" noValidate 
      onSubmit={handleSubmit(onSubmit)} 
      spacing={2} 
      sx={{ m: 2, width: '25ch' }}>

      <Controller
        name="area"
        control={control}
-        // rules={validationRules.area}
        render={({ field, fieldState }) => (
          <FormControl fullWidth error={fieldState.invalid}>
            <InputLabel id="area-label">地域</InputLabel>
            <Select
              labelId="area-label"
              label="  " // フォーカスを外した時のラベルの部分こ(これを指定しないとラベルとコントロール線が被る)
              {...field}
            >
              <MenuItem value='' sx={{color:'gray'}}>未選択</MenuItem>
              <MenuItem value={1}>北海道</MenuItem>
              <MenuItem value={2}>東北</MenuItem>
              <MenuItem value={4}>関東</MenuItem>
              <MenuItem value={5}>中部</MenuItem>
              <MenuItem value={6}>近畿</MenuItem>
              <MenuItem value={7}>中国</MenuItem>
              <MenuItem value={8}>四国</MenuItem>
              <MenuItem value={9}>九州沖縄</MenuItem>
            </Select>
            <FormHelperText>{fieldState.error?.message}</FormHelperText>
          </FormControl>
        )}
      />
      <Button variant="contained" type="submit" >
        送信する
      </Button>
    </Stack>
  )
}
React(ts) + ReactHookForm + MUIv5 RadioGroup
yup
import {
  Stack,
  RadioGroup,
  FormLabel,
  FormControlLabel,
  Radio,
  FormControl,
  Button,
  FormHelperText
} from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'
+ import { yupResolver } from '@hookform/resolvers/yup'
+ import * as yup from "yup"

- // type Inputs = {
- //   gender: number
- // }

- //  const validationRules = {
- //   gender: {
- //     validate: (value: number) => value !== -1 || 'いずれかを選択してください。'
- //   }
- // }

+ const schema = yup.object({
+  gender: yup.number().moreThan(-1,'いずれかを選択してください。')
+ })

+ type Inputs = yup.InferType<typeof schema>

export function InputReactHookFormRadioGroup() {
  const {
    control,
    handleSubmit
  } = useForm<Inputs>({
    defaultValues: { gender: -1 },
+    resolver: yupResolver(schema)
  })

 
  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: ${data.gender} typeof: ${typeof data.gender}`)
  }

  return (
    <Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} spacing={2} sx={{ m: 2, width: '25ch' }}>
      {/* 6.Controllerコンポーネントで TextFieldをReactHookFormと紐づけます。 */}
      <Controller
        name="gender"
        control={control}
-        // rules={validationRules.gender}
        render={({ field, fieldState }) => (
          <FormControl error={fieldState.invalid}>
            <FormLabel id="radio-buttons-group-label">Gender</FormLabel>
            <RadioGroup 
              aria-labelledby="radio-buttons-group-label" 
              value={field.value} name="gender">
              <FormControlLabel {...field} value={1} control={<Radio />} label="男性" />
              <FormControlLabel {...field} value={2} control={<Radio />} label="女性" />
              <FormControlLabel {...field} value={0} control={<Radio />} label="未回答" />
            </RadioGroup>
            <FormHelperText>{fieldState.error?.message}</FormHelperText>
          </FormControl>
        )}
      />

      <Button variant="contained" type="submit">
        送信する
      </Button>
    </Stack>
  )
}

zod
import {
  Stack,
  RadioGroup,
  FormLabel,
  FormControlLabel,
  Radio,
  FormControl,
  Button,
  FormHelperText
} from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'
+ import { zodResolver } from '@hookform/resolvers/zod'
+ import { z } from "zod"

- // type Inputs = {
- //   gender: number
- // }

- // const validationRules = {
- //   gender: {
- //     validate: (value: number) => value !== -1 || 'いずれかを選択してください。'
- //   }
- // }

+ const schema = z.object({
+   gender: z.number()
+       .or(z.string().transform(Number))
+       .refine(
+         (val) => val > 0, 'いずれかを選択してください。')
+ })

+ type Inputs = z.infer<typeof schema>

export function InputReactHookFormRadioGroup() {

  const {
    control,
    handleSubmit,
  } = useForm<Inputs>({
    defaultValues: { gender: -1 },
+     resolver: zodResolver(schema)
  })


  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: ${data.gender} typeof ${typeof data.gender}`)
  }

  return (
    <Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} spacing={2} sx={{ m: 2, width: '25ch' }}>
      
      <Controller
        name="gender"
        control={control}
-        // rules={validationRules.gender}
        render={({ field, fieldState }) => (
          <FormControl error={fieldState.invalid}>
            <FormLabel id="radio-buttons-group-label">Gender</FormLabel>
            <RadioGroup 
              aria-labelledby="radio-buttons-group-label" 
              value={field.value} name="gender">
              <FormControlLabel {...field} value={1} control={<Radio />} label="男性" />
              <FormControlLabel {...field} value={2} control={<Radio />} label="女性" />
              <FormControlLabel {...field} value={3} control={<Radio />} label="未回答" />
            </RadioGroup>
            <FormHelperText>{fieldState.error?.message}</FormHelperText>
          </FormControl>
        )}
      />

      <Button variant="contained" type="submit">
        送信する
      </Button>
    </Stack>
  )
}
React(ts) + ReactHookForm + MUIv5 DatePicker
yup
import * as React from 'react'
import { Stack, TextField, Button } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'
import { LocalizationProvider, DatePicker } from '@mui/lab'
import AdapterDateFns from '@mui/lab/AdapterDateFns'
import ja from 'date-fns/locale/ja'
+ import { yupResolver } from '@hookform/resolvers/yup'
+ import * as yup from "yup"

- // type Inputs = {
- //   applicationDate: Date | null
- // }

- // const validationRules = {
- //   applicationDate: {
- //     validate: (val: Date | null) => {
- //       if (val == null) {
- //         return '申請日を入力してください。'
- //       }
- //       if (Number.isNaN(val.getTime())) {
- //         return '日付を正しく入力してください。'
- //       }
- //       return true
- //     }
- //   }
- // }

+ const schema = yup.object({
+   applicationDate: yup.date()
+   .transform((value, originalvalue) => originalvalue == null ? undefined : value)
+   .required('申請日を入力してください。')
+   .typeError('日付を正しく入力してください。')
+ })
+ type Inputs = yup.InferType<typeof schema>


export function InputDateTimePicker() {
  const {
    control,
    handleSubmit,
  } = useForm<Inputs>({
    defaultValues: { applicationDate: new Date() },
+     resolver: yupResolver(schema)
  })

  

  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: ${data.applicationDate} typeof: ${typeof data.applicationDate}`)
  }

  return (
    <LocalizationProvider dateAdapter={AdapterDateFns} locale={ja}>
      <Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} spacing={2} sx={{ m: 2, width: '25ch' }}>
        <Controller
          name="applicationDate"
          control={control}
-          // rules={validationRules.applicationDate}
          render={({ field, fieldState }) => (
            <DatePicker
              label="申請日"
              inputFormat="yyyy年MM月dd日"
              mask="____年__月__日"
              leftArrowButtonText="前月を表示"
              rightArrowButtonText="次月を表示"
              toolbarTitle="日付選択"
              cancelText="キャンセル"
              okText="選択"
              toolbarFormat="yyyy年MM月dd日"
              renderInput={(params) => (
                <TextField
                  {...params}
                  error={fieldState.invalid}
                  helperText={fieldState.invalid ? fieldState?.error?.message : null}
                
                />
              )}
              PaperProps={{ sx: styles.paperprops }}
              DialogProps={{ sx: styles.mobiledialogprops }}
              {...field}
              
            />
          )}
        />

        <Button variant="contained" type="submit">
          送信する
        </Button>
      </Stack>
    </LocalizationProvider>
  )
}

const styles = {
  componentsProps: {
    color: 'green'
  },
  paperprops: {
    'div[role=presentation]': {
      display: 'flex',
      '& .PrivatePickersFadeTransitionGroup-root:first-of-type': {
        order: 2
      },
      '& .PrivatePickersFadeTransitionGroup-root:nth-of-type(2)': {
        order: 1,
        '& div::after': {
          content: '"年"'
        }
      },
      '& .MuiButtonBase-root': {
        order: 3
      }
    }
  },
  mobiledialogprops: {
    '.PrivatePickersToolbar-dateTitleContainer .MuiTypography-root': {
      fontSize: '1.5rem'
    },
    'div[role=presentation]:first-of-type': {
      display: 'flex',
      '& .PrivatePickersFadeTransitionGroup-root:first-of-type': {
        order: 2
      },
      '& .PrivatePickersFadeTransitionGroup-root:nth-of-type(2)': {
        order: 1,
        '& > div::after': {
          content: '"年"'
        }
      },
      '& .MuiButtonBase-root': {
        order: 3
      }
    }
  }
}

zod
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable @typescript-eslint/no-use-before-define */
import * as React from 'react'
import { Stack, TextField, Button } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'
import { LocalizationProvider, DatePicker } from '@mui/lab'
import AdapterDateFns from '@mui/lab/AdapterDateFns'
import ja from 'date-fns/locale/ja'
+ import { zodResolver } from '@hookform/resolvers/zod'
+ import { z } from "zod"

- // type Inputs = {
- //   applicationDate: Date | null
- // }

- // const validationRules = {
- //   applicationDate: {
- //     validate: (val: Date | null) => {
- //       if (val == null) {
- //         return '申請日を入力してください。'
- //       }
- //       if (Number.isNaN(val.getTime())) {
- //         return '日付を正しく入力してください。'
- //       }
- //       return true
- //     }
- //   }
- // }

+ const errMap: z.ZodErrorMap = (issue, _ctx) => {
+   let message
+   switch (issue.code) {
+     case z.ZodIssueCode.invalid_type:
+       message = `申請日を入力してください。`
+       break
+     case z.ZodIssueCode.invalid_date:
+       message = `日付を正しく入力してください。`
+       break
+     default:
+       message = _ctx.defaultError
+   }
+   return { message }
+ }


+ const schema = z.object({
+   applicationDate: z.date({errorMap: errMap})
+ })

+ type Inputs = z.infer<typeof schema>

export function InputDateTimePicker() {
  const {
    control,
    handleSubmit,
  } = useForm<Inputs>({
    defaultValues: { applicationDate: new Date() },
+    resolver: zodResolver(schema)
  })

  

  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: ${data.applicationDate}`)
  }

  return (
    <LocalizationProvider dateAdapter={AdapterDateFns} locale={ja}>
      <Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} spacing={2} sx={{ m: 2, width: '25ch' }}>
        <Controller
          name="applicationDate"
          control={control}
-          // rules={validationRules.applicationDate}
          render={({ field, fieldState }) => (
            <DatePicker
              label="申請日"
              inputFormat="yyyy年MM月dd日"
              mask="____年__月__日"
              leftArrowButtonText="前月を表示"
              rightArrowButtonText="次月を表示"
              toolbarTitle="日付選択"
              cancelText="キャンセル"
              okText="選択"
              toolbarFormat="yyyy年MM月dd日"
              renderInput={(params) => (
                <TextField
                  {...params}
                  error={fieldState.invalid}
                  helperText={fieldState.invalid ? fieldState?.error?.message : null}
                />
              )}
              PaperProps={{ sx: styles.paperprops }}
              DialogProps={{ sx: styles.mobiledialogprops }}
              {...field}
              
            />
          )}
        />

        <Button variant="contained" type="submit">
          送信する
        </Button>
      </Stack>
    </LocalizationProvider>
  )
}

const styles = {
  componentsProps: {
    color: 'green'
  },
  paperprops: {
    'div[role=presentation]': {
      display: 'flex',
      '& .PrivatePickersFadeTransitionGroup-root:first-of-type': {
        order: 2
      },
      '& .PrivatePickersFadeTransitionGroup-root:nth-of-type(2)': {
        order: 1,
        '& div::after': {
          content: '"年"'
        }
      },
      '& .MuiButtonBase-root': {
        order: 3
      }
    }
  },
  mobiledialogprops: {
    '.PrivatePickersToolbar-dateTitleContainer .MuiTypography-root': {
      fontSize: '1.5rem'
    },
    'div[role=presentation]:first-of-type': {
      display: 'flex',
      '& .PrivatePickersFadeTransitionGroup-root:first-of-type': {
        order: 2
      },
      '& .PrivatePickersFadeTransitionGroup-root:nth-of-type(2)': {
        order: 1,
        '& > div::after': {
          content: '"年"'
        }
      },
      '& .MuiButtonBase-root': {
        order: 3
      }
    }
  }
}
React(ts) + ReactHookForm + MUIv5 複数のCheckBox
yup
import { Stack, Button, FormControlLabel, Checkbox, FormControl, FormGroup, FormHelperText } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'
+ import { yupResolver } from '@hookform/resolvers/yup'
+ import * as yup from 'yup'

- // type Inputs = {
- //   checks: boolean[]
- //   checkerr: boolean
- // }

- // const validationRules = {
- //   checks: {
- //     validate: () => {
- //       clearErrors(`checkerr`)
- //       const checks = [getValues(`checks.${0}`), getValues(`checks.${1}`), getValues(`checks.${2}`)]
- //       if (checks.filter((v) => v === true).length < 2) {
- //         setError(`checkerr`, { message: 'いずれか2つ選択してください。' })
- //       }
- //       return true
- //     }
- //   }
- // }

+ const schema = yup.object({
+   checks: yup.array().of(yup.boolean().required()).required(),
+   checkerr: yup.boolean().required().test('checklength', 'いずれか2つ選択してください。', function() {
+      return this.parent.checks.filter((v:boolean) => v === true).length >= 2 
+   })
+ })

+ type Inputs = yup.InferType<typeof schema>


export function InputReactHookCheckBox() {
  const {
    control,
    handleSubmit,
    formState: { errors }
-    // getValues,
-    // clearErrors,
-    // setError
  } = useForm<Inputs>({
    defaultValues: {
      checks: [false, true, false],
      checkerr: false
    },
+    resolver: yupResolver(schema)
  })

  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: checks[0]=${data.checks[0]} checks[1]=${data.checks[1]} checks[2]=${data.checks[2]} typeof: ${typeof data.checks}`)
  }

  return (
    <Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} spacing={2} sx={{ m: 2, width: '25ch' }}>
      <FormControl fullWidth error={errors.checkerr !== undefined}>
        <span>いずれか2つ選択。</span>
        <FormGroup>
          <Controller
            name={`checks.${0}`}
            control={control}
-            // rules={validationRules.checks}
            render={({ field }) => (
              <FormControlLabel label="チェック 1" control={<Checkbox {...field} checked={field.value} />} />
            )}
          />

          <Controller
            name={`checks.${1}`}
            control={control}
            // rules={validationRules.checks}
            render={({ field }) => (
              <FormControlLabel label="チェック 2" control={<Checkbox {...field} checked={field.value} />} />
            )}
          />

          <Controller
            name={`checks.${2}`}
            control={control}
            // rules={validationRules.checks}
            render={({ field }) => (
              <FormControlLabel label="チェック 3" control={<Checkbox {...field} checked={field.value} />} />
            )}
          />
        </FormGroup>
        <FormHelperText>{errors.checkerr?.message}</FormHelperText>
      </FormControl>

      <Button variant="contained" type="submit">
        送信する
      </Button>
    </Stack>
  )
}
zod
import { Stack, Button, FormControlLabel, Checkbox, FormControl, FormGroup, FormHelperText } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'
+ import { zodResolver } from '@hookform/resolvers/zod'
+ import { z } from "zod"

- // type Inputs = {
- //   checks: boolean[]
- //   checkerr: boolean
- // }

- // const validationRules = {
- //   checks: {
- //     validate: () => {
- //       clearErrors(`checkerr`)
- //       const checks = [getValues(`checks.${0}`), getValues(`checks.${1}`), getValues(`checks.${2}`)]
- //       if (checks.filter((v) => v === true).length < 2) {
- //         setError(`checkerr`, { message: 'いずれか2つ選択してください。' })
- //       }
- //       return true
- //     }
- //   }
- // }

+ const schema = z.object({
+   checks: z.array(z.boolean()),
+   checkerr: z.boolean()
+ })
+ .refine(data => data.checks.filter((v) => v === true).length >= 2, {
+     message: 'いずれか2つ選択してください。',
+     path: ['checkerr'], 
+   })

+ type Inputs = z.infer<typeof schema>


export function InputReactHookCheckBox() {
  const {
    control,
    handleSubmit,
    formState: { errors }
-    // getValues,
-    // clearErrors,
-    // setError
  } = useForm<Inputs>({
    defaultValues: {
      checks: [false, true, false],
      checkerr: false
    },
+    resolver: zodResolver(schema)
  })

  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    console.log(`submit: checks[0]=${data.checks[0]} checks[1]=${data.checks[1]} checks[2]=${data.checks[2]} typeof: ${typeof data.checks}`)
  }

  return (
    <Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} spacing={2} sx={{ m: 2, width: '25ch' }}>
      <FormControl fullWidth error={errors.checkerr !== undefined}>
        <span>いずれか2つ選択。</span>
        <FormGroup>
          <Controller
            name={`checks.${0}`}
            control={control}
-            // rules={validationRules.checks}
            render={({ field }) => (
              <FormControlLabel label="チェック 1" control={<Checkbox {...field} checked={field.value} />} />
            )}
          />

          <Controller
            name={`checks.${1}`}
            control={control}
            // rules={validationRules.checks}
            render={({ field }) => (
              <FormControlLabel label="チェック 2" control={<Checkbox {...field} checked={field.value} />} />
            )}
          />

          <Controller
            name={`checks.${2}`}
            control={control}
            // rules={validationRules.checks}
            render={({ field }) => (
              <FormControlLabel label="チェック 3" control={<Checkbox {...field} checked={field.value} />} />
            )}
          />
        </FormGroup>
        <FormHelperText>{errors.checkerr?.message}</FormHelperText>
      </FormControl>

      <Button variant="contained" type="submit">
        送信する
      </Button>
    </Stack>
  )
}

■ 書き比べた結果

型推論はやはり zod が優位です。
個人的にはスキーマ定義の見やすさは yup のほうがわかりやすいのではと思います。

分かりやすさを見るために、上記で書き比べた yup と zod のサンプルコードから スキーマ定義部分だけを抜き出しました。

TextField

TextFieldではあまり違いは感じません。

yup
const schema = yup.object({
  name: yup.string()
        .required('名前を入力してください。')
        .min(4, '4文字以上で入力してください。'),
})
zod
const schema = z.object({
  name: z.string()
        .nonempty('名前を入力してください。')
        .min(4, '4文字以上で入力してください。')
})
Select
yup
const schema = yup.object({
  area: yup.number()
    .transform((value, originalvalue) => originalvalue === '' ? undefined : value)
    .required('いずれかを選択してください。')
})
zod
const schema = z.object({
  area: z.union([z.string(), z.number()])
       .refine(
        (val) => val !== '', { message: 'いずれかを選択してください。' }
       )
})
RadioGroup
yup
const schema = yup.object({
  gender: yup.number().moreThan(-1,'いずれかを選択してください。')
})

zod の場合は RadioGroup の選択値は number で受け取れず string になったので型変換し、その後にチェーンでnumber.positive()が使えなかったので.refine()で検証しています。

zod
const schema = z.object({
  gender: z.number()
      .or(z.string().transform(Number))
      .refine(
        (val) => val > 0, 'いずれかを選択してください。')
})
DatePicker

DatePickerで未入力の時の値はnullになります。
yupではtransform()で null を undefined に変換して、required()で必須チェックしています。
またDatePickerは日付として中途半端な値を入力することができますが、この中途半端な日付の値をtypeError()でチェックしています。

yup
const schema = yup.object({
  applicationDate: yup.date()
  .transform((value, originalvalue) => originalvalue == null ? undefined : value)
  .required('申請日を入力してください。')
  .typeError('日付を正しく入力してください。')
})

zod の場合はz.date()だけで、必須チェックと日付として中途半端な値のチェックができます。
日付として中途半端な値のときのエラー「invalid_date」のメッセージを変更するのに、エラーマップでメッセージを設定するしか方法がありません。
エラーマップはグローバルで設定したり、下記のように個別に設定することも可能です。
通常は汎用的なメッセージを指定しておけば問題ないですが、個別のメッセージを表示しなければいけない場合は面倒です。

zod
const errMap: z.ZodErrorMap = (issue, _ctx) => {
  let message
  switch (issue.code) {
    case z.ZodIssueCode.invalid_type:
      message = `申請日を入力してください。`
      break
    case z.ZodIssueCode.invalid_date:
      message = `日付を正しく入力してください。`
      break
    default:
      message = _ctx.defaultError
  }
  return { message }
}

const schema = z.object({
  applicationDate: z.date({errorMap: errMap})
})
複数のCheckBox

スキーマchecks: yup.array().of(yup.boolean().required()).required()(型推論boolean[])の定義は冗長です。

yup
const schema = yup.object({
  checks: yup.array().of(yup.boolean().required()).required(),
  checkerr: yup.boolean().required().test('checklength', 'いずれか2つ選択してください。', function() {
     return this.parent.checks.filter((v:boolean) => v === true).length >= 2 
  })
})
zod
const schema = z.object({
   checks: z.array(z.boolean()),
   checkerr: z.boolean()
 })
 .refine(data => data.checks.filter((v) => v === true).length >= 2, {
     message: 'いずれか2つ選択してください。',
     path: ['checkerr'], 
   })

結論

yup にするか zod にするか?
サンプルコードを書く限り、そんなに大きな違いがあるようには思えませんでした。

個人的な感想ですが、
yup のほうがコード量は増えますが、ライブラリの動作を把握していなくてもパッと見で仕様が理解できるのではと思いました。
また、今回サンプルコードを書いてみるにあたって、yupで詰まった所は検索すれば解決策がすぐヒットするのに対して、zod はあまり情報がヒットせずに少し時間がかかりました。
zod も慣れればそんなに迷わず書けそうです。
しかし yup は初見から迷わずに書けました。

ということで、私は yup にしようと思います。