Yup

JavaScriptとTypeScript向けのスキーマビルダーによるオブジェクトバリデーションライブラリ

概要

Yupは、JavaScriptおよびTypeScript向けの強力なスキーマビルダーとバリデーションライブラリです。直感的なAPIを使用して、複雑なデータ構造の検証ルールを宣言的に定義できます。Joiライブラリに影響を受けながらも、クライアントサイドでの使用に最適化されており、ブラウザ環境でも軽量に動作します。

主な特徴

  • 宣言的なスキーマ定義: チェーンメソッドによる直感的なバリデーションルールの構築
  • TypeScript完全対応: 優れた型推論とInferTypeによる型生成
  • 豊富な組み込みバリデーター: 文字列、数値、日付、配列、オブジェクトなどの検証
  • カスタムバリデーション: 独自の検証ロジックの実装が容易
  • 条件付きバリデーション: 動的な検証ルールの適用
  • 国際化対応: カスタムエラーメッセージとローカライゼーション
  • 非同期バリデーション: APIコールを含む非同期検証のサポート

インストール

# npm
npm install yup

# yarn
yarn add yup

# pnpm
pnpm add yup

基本的な使い方

スキーマの定義と検証

import * as yup from 'yup';

// スキーマの定義
const userSchema = yup.object({
  name: yup.string().required('名前は必須です'),
  age: yup.number().positive().integer().required('年齢は必須です'),
  email: yup.string().email('有効なメールアドレスを入力してください'),
  website: yup.string().url().nullable(),
  createdOn: yup.date().default(() => new Date()),
});

// 検証の実行
const user = {
  name: '田中太郎',
  age: 30,
  email: '[email protected]',
};

// 同期的な検証
try {
  const validatedUser = userSchema.validateSync(user);
  console.log(validatedUser);
} catch (err) {
  console.error(err.errors);
}

// 非同期検証
userSchema
  .validate(user)
  .then(validatedUser => {
    console.log(validatedUser);
  })
  .catch(err => {
    console.error(err.errors);
  });

組み込みバリデーションメソッド

文字列バリデーション

const stringSchema = yup.string()
  .required('必須項目です')
  .min(3, '3文字以上入力してください')
  .max(20, '20文字以内で入力してください')
  .matches(/^[a-zA-Z0-9]+$/, '英数字のみ使用可能です')
  .email('有効なメールアドレスを入力してください')
  .url('有効なURLを入力してください')
  .lowercase('小文字で入力してください')
  .uppercase('大文字で入力してください')
  .trim(); // 前後の空白を削除

数値バリデーション

const numberSchema = yup.number()
  .required('必須項目です')
  .positive('正の数を入力してください')
  .negative('負の数を入力してください')
  .integer('整数を入力してください')
  .min(0, '0以上の値を入力してください')
  .max(100, '100以下の値を入力してください')
  .lessThan(100, '100未満の値を入力してください')
  .moreThan(0, '0より大きい値を入力してください');

日付バリデーション

const dateSchema = yup.date()
  .required('必須項目です')
  .min(new Date('2020-01-01'), '2020年1月1日以降の日付を選択してください')
  .max(new Date(), '今日以前の日付を選択してください');

配列バリデーション

const arraySchema = yup.array()
  .of(yup.string().required())
  .required('必須項目です')
  .min(1, '少なくとも1つ選択してください')
  .max(5, '最大5つまで選択可能です');

// 一意性の検証
const uniqueArraySchema = yup.array()
  .of(yup.string())
  .test('unique', '重複する値があります', (values) => {
    return values?.length === new Set(values).size;
  });

オブジェクトバリデーション

const addressSchema = yup.object({
  street: yup.string().required('住所は必須です'),
  city: yup.string().required('市区町村は必須です'),
  state: yup.string().required('都道府県は必須です'),
  zip: yup.string().matches(/^\d{3}-?\d{4}$/, '有効な郵便番号を入力してください'),
});

const personSchema = yup.object({
  name: yup.string().required(),
  age: yup.number().positive().integer(),
  address: addressSchema,
  hobbies: yup.array().of(yup.string()),
});

カスタムバリデーション

テストメソッドの使用

const passwordSchema = yup.string()
  .required('パスワードは必須です')
  .min(8, 'パスワードは8文字以上必要です')
  .test('has-uppercase', '大文字を含めてください', (value) => {
    return /[A-Z]/.test(value || '');
  })
  .test('has-lowercase', '小文字を含めてください', (value) => {
    return /[a-z]/.test(value || '');
  })
  .test('has-number', '数字を含めてください', (value) => {
    return /\d/.test(value || '');
  });

カスタムメソッドの追加

// Yupを拡張してカスタムメソッドを追加
yup.addMethod(yup.string, 'phone', function(message) {
  return this.test('phone', message || '有効な電話番号を入力してください', function(value) {
    const phoneRegex = /^0\d{1,4}-?\d{1,4}-?\d{4}$/;
    return !value || phoneRegex.test(value);
  });
});

// TypeScript向けの型定義
declare module 'yup' {
  interface StringSchema {
    phone(message?: string): StringSchema;
  }
}

// 使用例
const phoneSchema = yup.string().phone().required();

条件付きバリデーション

when()メソッドの使用

const purchaseSchema = yup.object({
  isMember: yup.boolean(),
  memberNumber: yup.string().when('isMember', {
    is: true,
    then: (schema) => schema.required('会員番号は必須です'),
    otherwise: (schema) => schema.nullable(),
  }),
  paymentMethod: yup.string().required(),
  creditCardNumber: yup.string().when('paymentMethod', {
    is: 'credit',
    then: (schema) => schema
      .required('クレジットカード番号は必須です')
      .matches(/^\d{16}$/, '16桁の数字を入力してください'),
    otherwise: (schema) => schema.nullable(),
  }),
});

複数フィールドの条件

const formSchema = yup.object({
  startDate: yup.date().required('開始日は必須です'),
  endDate: yup.date()
    .required('終了日は必須です')
    .when('startDate', (startDate, schema) => {
      return schema.min(startDate, '終了日は開始日より後の日付を選択してください');
    }),
});

エラーハンドリング

カスタムエラーメッセージ

const schema = yup.object({
  email: yup.string()
    .required('メールアドレスを入力してください')
    .email('正しいメールアドレスの形式で入力してください'),
  password: yup.string()
    .required('パスワードを入力してください')
    .min(8, ({ min }) => `パスワードは${min}文字以上必要です`),
});

// バリデーションエラーの取得
try {
  schema.validateSync(data, { abortEarly: false });
} catch (err) {
  if (err instanceof yup.ValidationError) {
    // すべてのエラーを取得
    console.log(err.errors);
    
    // フィールドごとのエラー
    err.inner.forEach((error) => {
      console.log(`${error.path}: ${error.message}`);
    });
  }
}

非同期エラーハンドリング

const asyncSchema = yup.object({
  email: yup.string()
    .email()
    .test('email-exists', 'このメールアドレスは既に登録されています', async (value) => {
      // API呼び出しなどの非同期処理
      const exists = await checkEmailExists(value);
      return !exists;
    }),
});

asyncSchema.validate(data)
  .catch((err: yup.ValidationError) => {
    console.error('Validation failed:', err.errors);
  });

TypeScript統合

型推論とInferType

import * as yup from 'yup';

const userSchema = yup.object({
  id: yup.number().required(),
  name: yup.string().required(),
  email: yup.string().email().required(),
  age: yup.number().positive().nullable(),
  roles: yup.array().of(yup.string().required()).required(),
  settings: yup.object({
    theme: yup.string().oneOf(['light', 'dark']).required(),
    notifications: yup.boolean().required(),
  }).required(),
});

// スキーマから型を推論
type User = yup.InferType<typeof userSchema>;
// {
//   id: number;
//   name: string;
//   email: string;
//   age: number | null;
//   roles: string[];
//   settings: {
//     theme: 'light' | 'dark';
//     notifications: boolean;
//   };
// }

// 型安全な検証
const validateUser = async (data: unknown): Promise<User> => {
  return await userSchema.validate(data);
};

ジェネリック型の使用

interface UserInput {
  name: string;
  email: string;
  age?: number;
}

const createUserSchema = <T extends UserInput>(): yup.Schema<T> => {
  return yup.object({
    name: yup.string().required(),
    email: yup.string().email().required(),
    age: yup.number().positive().optional(),
  }) as yup.Schema<T>;
};

フォームライブラリとの統合

React Hook Formとの統合

import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';

const schema = yup.object({
  firstName: yup.string().required('名は必須です'),
  lastName: yup.string().required('姓は必須です'),
  email: yup.string().email('有効なメールアドレスを入力してください').required('メールアドレスは必須です'),
});

type FormData = yup.InferType<typeof schema>;

function MyForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: yupResolver(schema),
  });

  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('firstName')} />
      {errors.firstName && <span>{errors.firstName.message}</span>}
      
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      
      <button type="submit">送信</button>
    </form>
  );
}

Formikとの統合

import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as yup from 'yup';

const validationSchema = yup.object({
  email: yup.string().email('無効なメールアドレス').required('必須'),
  password: yup.string().min(8, '8文字以上').required('必須'),
});

function LoginForm() {
  return (
    <Formik
      initialValues={{ email: '', password: '' }}
      validationSchema={validationSchema}
      onSubmit={(values) => {
        console.log(values);
      }}
    >
      {({ errors, touched }) => (
        <Form>
          <Field name="email" type="email" />
          <ErrorMessage name="email" component="div" />
          
          <Field name="password" type="password" />
          <ErrorMessage name="password" component="div" />
          
          <button type="submit">ログイン</button>
        </Form>
      )}
    </Formik>
  );
}

実践的な例

ユーザー登録フォームの検証

const registrationSchema = yup.object({
  username: yup.string()
    .required('ユーザー名は必須です')
    .min(3, 'ユーザー名は3文字以上必要です')
    .max(20, 'ユーザー名は20文字以内にしてください')
    .matches(/^[a-zA-Z0-9_]+$/, 'ユーザー名は英数字とアンダースコアのみ使用可能です'),
  
  email: yup.string()
    .required('メールアドレスは必須です')
    .email('有効なメールアドレスを入力してください')
    .test('email-domain', '企業メールアドレスを使用してください', (value) => {
      return value ? !value.endsWith('@gmail.com') && !value.endsWith('@yahoo.com') : false;
    }),
  
  password: yup.string()
    .required('パスワードは必須です')
    .min(8, 'パスワードは8文字以上必要です')
    .matches(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
      'パスワードは大文字、小文字、数字、特殊文字を含む必要があります'
    ),
  
  confirmPassword: yup.string()
    .required('パスワード確認は必須です')
    .oneOf([yup.ref('password')], 'パスワードが一致しません'),
  
  birthDate: yup.date()
    .required('生年月日は必須です')
    .max(new Date(), '未来の日付は選択できません')
    .test('age', '18歳以上である必要があります', (value) => {
      if (!value) return false;
      const age = new Date().getFullYear() - value.getFullYear();
      return age >= 18;
    }),
  
  terms: yup.boolean()
    .required('利用規約への同意は必須です')
    .oneOf([true], '利用規約に同意する必要があります'),
});

動的フォームの検証

const dynamicFormSchema = yup.object({
  fields: yup.array().of(
    yup.object({
      type: yup.string().oneOf(['text', 'number', 'email', 'date']).required(),
      label: yup.string().required('ラベルは必須です'),
      value: yup.mixed().when('type', {
        is: 'text',
        then: () => yup.string().required('テキストフィールドは必須です'),
        otherwise: yup.mixed().when('type', {
          is: 'number',
          then: () => yup.number().required('数値フィールドは必須です'),
          otherwise: yup.mixed().when('type', {
            is: 'email',
            then: () => yup.string().email('有効なメールアドレスを入力してください').required(),
            otherwise: yup.mixed().when('type', {
              is: 'date',
              then: () => yup.date().required('日付フィールドは必須です'),
            }),
          }),
        }),
      }),
    })
  ),
});

ベストプラクティス

1. スキーマの再利用

// 共通スキーマの定義
const commonSchemas = {
  email: yup.string().email('有効なメールアドレスを入力してください').required('メールアドレスは必須です'),
  password: yup.string().min(8, 'パスワードは8文字以上必要です').required('パスワードは必須です'),
  phoneNumber: yup.string().matches(/^0\d{1,4}-?\d{1,4}-?\d{4}$/, '有効な電話番号を入力してください'),
};

// スキーマの組み合わせ
const loginSchema = yup.object({
  email: commonSchemas.email,
  password: commonSchemas.password,
});

const profileSchema = yup.object({
  email: commonSchemas.email,
  phone: commonSchemas.phoneNumber,
  bio: yup.string().max(500, '自己紹介は500文字以内で入力してください'),
});

2. エラーメッセージの国際化

import { setLocale } from 'yup';

// 日本語ロケールの設定
setLocale({
  mixed: {
    default: '無効な値です',
    required: '必須項目です',
    oneOf: '次のいずれかの値にする必要があります: ${values}',
    notOneOf: '次の値以外にする必要があります: ${values}',
  },
  string: {
    length: '${length}文字にする必要があります',
    min: '${min}文字以上入力してください',
    max: '${max}文字以内で入力してください',
    email: '有効なメールアドレスを入力してください',
    url: '有効なURLを入力してください',
    trim: '前後の空白は使用できません',
    lowercase: '小文字で入力してください',
    uppercase: '大文字で入力してください',
  },
  number: {
    min: '${min}以上の値を入力してください',
    max: '${max}以下の値を入力してください',
    lessThan: '${less}未満の値を入力してください',
    moreThan: '${more}より大きい値を入力してください',
    positive: '正の数を入力してください',
    negative: '負の数を入力してください',
    integer: '整数を入力してください',
  },
  date: {
    min: '${min}以降の日付を選択してください',
    max: '${max}以前の日付を選択してください',
  },
  array: {
    min: '${min}個以上選択してください',
    max: '${max}個以内で選択してください',
  },
});

3. パフォーマンスの最適化

// lazyを使用した遅延評価
const userSchema = yup.lazy((value) => {
  if (value?.type === 'admin') {
    return yup.object({
      type: yup.string().required(),
      adminCode: yup.string().required('管理者コードは必須です'),
      permissions: yup.array().of(yup.string()).required(),
    });
  }
  return yup.object({
    type: yup.string().required(),
    email: yup.string().email().required(),
  });
});

// 部分的な検証
const partialSchema = userSchema.pick(['email', 'name']);

// 条件付きの必須フィールド
const conditionalSchema = yup.object({
  hasAddress: yup.boolean(),
  address: yup.string().when('hasAddress', {
    is: true,
    then: (schema) => schema.required('住所は必須です'),
  }),
});

他のバリデーションライブラリとの比較

Zodとの比較

  • Yup: より成熟したエコシステム、豊富なバリデーションメソッド
  • Zod: TypeScript-firstの設計、より優れた型推論

Joiとの比較

  • Yup: クライアントサイド向けに最適化、軽量
  • Joi: サーバーサイド向け、より豊富な機能

ValibotやSuperstructとの比較

  • Yup: 広範な採用実績、フォームライブラリとの統合が充実
  • Valibot/Superstruct: より軽量、モジュラーな設計

まとめ

Yupは、JavaScriptとTypeScriptプロジェクトにおいて、強力で柔軟なバリデーション機能を提供します。宣言的なAPIにより、複雑なバリデーションルールも読みやすく保守しやすいコードで実装できます。React Hook FormやFormikなどの人気フォームライブラリとの優れた統合により、フォームバリデーションの実装が大幅に簡素化されます。プロジェクトの要件に応じて、適切なバリデーション戦略を選択し、Yupの強力な機能を活用してください。