Joi

TypeScriptバリデーションライブラリスキーマNode.jsExpressAPI

GitHub概要

hapijs/joi

The most powerful data validation library for JS

スター21,179
ウォッチ174
フォーク1,516
作成日:2012年9月16日
言語:JavaScript
ライセンス:Other

トピックス

hapijavascriptschemavalidation

スター履歴

hapijs/joi Star History
データ取得日時: 2025/10/22 09:49

Joi

概要

JoiはTypeScriptプロジェクトで使用できる「最も強力なJavaScript用データバリデーションライブラリ」として設計された、成熟したスキーマベースのバリデーションライブラリです。豊富なバリデーションルールと柔軟なカスタマイズ機能により、複雑なAPI要件やフォーム検証に対応可能。TypeScriptとの組み合わせにより、型安全性を保ちながら実行時のデータ検証を行えます。Express.jsやNestJSなどのNode.jsフレームワークとの統合により、堅牢なサーバーサイドアプリケーション開発を支援します。

特徴

  • 包括的なバリデーション: プリミティブ型から複雑なネストしたオブジェクトまで対応
  • 豊富なルールセット: 文字列、数値、日付、配列、オブジェクトの詳細な制約条件
  • TypeScript統合: 型定義とスキーマの一致による型安全なバリデーション
  • Express/NestJS対応: ミドルウェアとしての簡単な統合
  • カスタムバリデーション: 独自のバリデーションロジックとエラーメッセージ
  • 条件付きバリデーション: 他のフィールドに依存する動的な検証ルール
  • 詳細なエラー報告: 開発者にとって分かりやすいエラーメッセージ
  • 長年の実績: Node.jsエコシステムでの安定した採用実績

インストール

npm install joi
npm install @types/joi

# または
yarn add joi
yarn add --dev @types/joi

# または
pnpm add joi
pnpm add -D @types/joi

使用例

基本的なスキーマ定義

import Joi from 'joi';

// 基本的なユーザースキーマ
const UserSchema = Joi.object({
  username: Joi.string()
    .alphanum()
    .min(3)
    .max(30)
    .required(),
  
  email: Joi.string()
    .email({ minDomainSegments: 2 })
    .required(),
  
  password: Joi.string()
    .min(8)
    .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
    .required()
    .messages({
      'string.pattern.base': 'パスワードには英大文字、英小文字、数字、特殊文字を含める必要があります'
    }),
  
  age: Joi.number()
    .integer()
    .min(18)
    .max(120)
    .optional(),
  
  birthDate: Joi.date()
    .max('now')
    .optional(),
  
  isActive: Joi.boolean()
    .default(true)
});

// TypeScriptインターフェースの定義
interface User {
  username: string;
  email: string;
  password: string;
  age?: number;
  birthDate?: Date;
  isActive: boolean;
}

// バリデーション関数
function validateUser(data: unknown): { success: true; data: User } | { success: false; error: Joi.ValidationError } {
  const result = UserSchema.validate(data);
  
  if (result.error) {
    return { success: false, error: result.error };
  }
  
  return { success: true, data: result.value as User };
}

// 使用例
const userData = {
  username: "john123",
  email: "[email protected]",
  password: "MySecure123!",
  age: 25
};

const validation = validateUser(userData);
if (validation.success) {
  console.log("バリデーション成功:", validation.data);
  // validation.dataはUser型として型安全
} else {
  console.error("バリデーションエラー:", validation.error.details);
}

高度なスキーマ定義

// 複雑なプロダクトスキーマ
const ProductSchema = Joi.object({
  name: Joi.string()
    .min(1)
    .max(100)
    .required()
    .trim(),
  
  description: Joi.string()
    .max(1000)
    .optional()
    .allow(''),
  
  price: Joi.number()
    .positive()
    .precision(2)
    .required(),
  
  currency: Joi.string()
    .valid('JPY', 'USD', 'EUR')
    .default('JPY'),
  
  category: Joi.string()
    .valid('electronics', 'clothing', 'books', 'food', 'other')
    .required(),
  
  tags: Joi.array()
    .items(Joi.string().trim().min(1))
    .min(1)
    .max(10)
    .unique()
    .default([]),
  
  specifications: Joi.object({
    weight: Joi.number().positive().optional(),
    dimensions: Joi.object({
      width: Joi.number().positive().required(),
      height: Joi.number().positive().required(),
      depth: Joi.number().positive().required()
    }).optional(),
    color: Joi.string().optional(),
    material: Joi.string().optional(),
    warranty: Joi.object({
      period: Joi.number().integer().min(0).max(120), // 月単位
      type: Joi.string().valid('limited', 'full', 'none').default('limited')
    }).optional()
  }).optional(),
  
  inventory: Joi.object({
    inStock: Joi.boolean().default(true),
    quantity: Joi.number().integer().min(0).required(),
    lowStockThreshold: Joi.number().integer().min(0).default(5)
  }).required(),
  
  metadata: Joi.object()
    .pattern(Joi.string(), [Joi.string(), Joi.number(), Joi.boolean()])
    .optional()
});

interface Product {
  name: string;
  description?: string;
  price: number;
  currency: 'JPY' | 'USD' | 'EUR';
  category: 'electronics' | 'clothing' | 'books' | 'food' | 'other';
  tags: string[];
  specifications?: {
    weight?: number;
    dimensions?: {
      width: number;
      height: number;
      depth: number;
    };
    color?: string;
    material?: string;
    warranty?: {
      period: number;
      type: 'limited' | 'full' | 'none';
    };
  };
  inventory: {
    inStock: boolean;
    quantity: number;
    lowStockThreshold: number;
  };
  metadata?: { [key: string]: string | number | boolean };
}

条件付きバリデーション

// ユーザータイプに基づく条件付きバリデーション
const UserAccountSchema = Joi.object({
  userType: Joi.string()
    .valid('individual', 'business', 'premium')
    .required(),
  
  name: Joi.string()
    .min(1)
    .max(100)
    .required(),
  
  email: Joi.string()
    .email()
    .required(),
  
  // 個人アカウント用のフィールド
  personalInfo: Joi.object({
    firstName: Joi.string().required(),
    lastName: Joi.string().required(),
    dateOfBirth: Joi.date().max('now').required()
  }).when('userType', {
    is: 'individual',
    then: Joi.required(),
    otherwise: Joi.forbidden()
  }),
  
  // ビジネスアカウント用のフィールド
  businessInfo: Joi.object({
    companyName: Joi.string().required(),
    taxId: Joi.string().required(),
    industry: Joi.string().required(),
    employeeCount: Joi.number().integer().min(1)
  }).when('userType', {
    is: 'business',
    then: Joi.required(),
    otherwise: Joi.forbidden()
  }),
  
  // プレミアムアカウント用のフィールド
  premiumFeatures: Joi.object({
    maxProjects: Joi.number().integer().min(1).max(1000),
    prioritySupport: Joi.boolean().default(true),
    customDomain: Joi.string().domain().optional()
  }).when('userType', {
    is: 'premium',
    then: Joi.required(),
    otherwise: Joi.forbidden()
  }),
  
  // 共通フィールド
  preferences: Joi.object({
    language: Joi.string().valid('ja', 'en', 'ko').default('ja'),
    notifications: Joi.object({
      email: Joi.boolean().default(true),
      sms: Joi.boolean().default(false),
      push: Joi.boolean().default(true)
    }).default({})
  }).default({})
});

// 複数フィールドに依存する複雑な条件
const PaymentSchema = Joi.object({
  paymentMethod: Joi.string()
    .valid('card', 'bank_transfer', 'digital_wallet')
    .required(),
  
  amount: Joi.number()
    .positive()
    .precision(2)
    .required(),
  
  // カード支払い用
  cardInfo: Joi.object({
    cardNumber: Joi.string().creditCard().required(),
    expiryMonth: Joi.number().integer().min(1).max(12).required(),
    expiryYear: Joi.number().integer().min(new Date().getFullYear()).required(),
    cvv: Joi.string().pattern(/^\d{3,4}$/).required(),
    holderName: Joi.string().required()
  }).when('paymentMethod', {
    is: 'card',
    then: Joi.required(),
    otherwise: Joi.forbidden()
  }),
  
  // 銀行振込用
  bankInfo: Joi.object({
    bankCode: Joi.string().pattern(/^\d{4}$/).required(),
    accountNumber: Joi.string().pattern(/^\d{7,8}$/).required(),
    accountType: Joi.string().valid('checking', 'savings').required()
  }).when('paymentMethod', {
    is: 'bank_transfer',
    then: Joi.required(),
    otherwise: Joi.forbidden()
  }),
  
  // デジタルウォレット用
  walletInfo: Joi.object({
    walletProvider: Joi.string().valid('paypal', 'apple_pay', 'google_pay').required(),
    walletId: Joi.string().required()
  }).when('paymentMethod', {
    is: 'digital_wallet',
    then: Joi.required(),
    otherwise: Joi.forbidden()
  })
}).custom((value, helpers) => {
  // 高額取引の追加検証
  if (value.amount > 100000 && value.paymentMethod === 'card') {
    return helpers.error('payment.high_amount_card_restricted');
  }
  return value;
}).messages({
  'payment.high_amount_card_restricted': '10万円以上の支払いではカード決済はご利用いただけません'
});

カスタムバリデーション

// 日本語特有のバリデーション
const JapaneseUserSchema = Joi.object({
  name: Joi.string()
    .custom((value, helpers) => {
      // ひらがな、カタカナ、漢字、英数字のみ許可
      const japanesePattern = /^[ひらがなカタカナ漢字a-zA-Z0-9\s]+$/u;
      if (!japanesePattern.test(value)) {
        return helpers.error('string.japanese');
      }
      return value;
    })
    .required()
    .messages({
      'string.japanese': '名前はひらがな、カタカナ、漢字、英数字のみ使用可能です'
    }),
  
  furigana: Joi.string()
    .custom((value, helpers) => {
      // ひらがなのみ許可
      const hiraganaPattern = /^[ひらがな\s]+$/u;
      if (!hiraganaPattern.test(value)) {
        return helpers.error('string.hiragana');
      }
      return value;
    })
    .required()
    .messages({
      'string.hiragana': 'ふりがなはひらがなのみ使用可能です'
    }),
  
  phoneNumber: Joi.string()
    .custom((value, helpers) => {
      // 日本の電話番号形式
      const phonePattern = /^(\+81|0)\d{1,4}-\d{1,4}-\d{4}$/;
      if (!phonePattern.test(value)) {
        return helpers.error('string.japanese_phone');
      }
      return value;
    })
    .required()
    .messages({
      'string.japanese_phone': '有効な日本の電話番号を入力してください(例: 03-1234-5678)'
    }),
  
  postalCode: Joi.string()
    .pattern(/^\d{3}-\d{4}$/)
    .required()
    .messages({
      'string.pattern.base': '郵便番号は123-4567の形式で入力してください'
    }),
  
  address: Joi.object({
    prefecture: Joi.string()
      .valid(
        '北海道', '青森県', '岩手県', '宮城県', '秋田県', '山形県', '福島県',
        '茨城県', '栃木県', '群馬県', '埼玉県', '千葉県', '東京都', '神奈川県',
        '新潟県', '富山県', '石川県', '福井県', '山梨県', '長野県', '岐阜県',
        '静岡県', '愛知県', '三重県', '滋賀県', '京都府', '大阪府', '兵庫県',
        '奈良県', '和歌山県', '鳥取県', '島根県', '岡山県', '広島県', '山口県',
        '徳島県', '香川県', '愛媛県', '高知県', '福岡県', '佐賀県', '長崎県',
        '熊本県', '大分県', '宮崎県', '鹿児島県', '沖縄県'
      )
      .required(),
    city: Joi.string().min(1).required(),
    street: Joi.string().min(1).required(),
    building: Joi.string().optional().allow('')
  }).required()
});

// 複雑なビジネスロジックのバリデーション
const OrderSchema = Joi.object({
  customerId: Joi.string().required(),
  items: Joi.array()
    .items(Joi.object({
      productId: Joi.string().required(),
      quantity: Joi.number().integer().min(1).required(),
      price: Joi.number().positive().required()
    }))
    .min(1)
    .required(),
  
  shippingAddress: Joi.object({
    // 住所スキーマは上記と同様
  }).required(),
  
  paymentMethod: Joi.string()
    .valid('card', 'bank_transfer', 'cod')
    .required()
}).custom(async (value, helpers) => {
  // 非同期バリデーション例(データベースチェック)
  try {
    // 顧客存在チェック(実際にはデータベースアクセス)
    const customerExists = await checkCustomerExists(value.customerId);
    if (!customerExists) {
      return helpers.error('order.customer_not_found');
    }
    
    // 在庫チェック
    for (const item of value.items) {
      const stockAvailable = await checkStock(item.productId, item.quantity);
      if (!stockAvailable) {
        return helpers.error('order.insufficient_stock', { productId: item.productId });
      }
    }
    
    // 代金引換の地域制限チェック
    if (value.paymentMethod === 'cod') {
      const codAvailable = await checkCODAvailability(value.shippingAddress);
      if (!codAvailable) {
        return helpers.error('order.cod_not_available');
      }
    }
    
    return value;
  } catch (error) {
    return helpers.error('order.validation_failed');
  }
}).messages({
  'order.customer_not_found': '指定された顧客が見つかりません',
  'order.insufficient_stock': '商品ID {#productId} の在庫が不足しています',
  'order.cod_not_available': 'この地域では代金引換はご利用いただけません',
  'order.validation_failed': 'バリデーション処理中にエラーが発生しました'
});

// ヘルパー関数(実装例)
async function checkCustomerExists(customerId: string): Promise<boolean> {
  // データベースチェックのモック
  return Promise.resolve(customerId !== 'invalid');
}

async function checkStock(productId: string, quantity: number): Promise<boolean> {
  // 在庫チェックのモック
  return Promise.resolve(quantity <= 10);
}

async function checkCODAvailability(address: any): Promise<boolean> {
  // 代金引換可能地域チェックのモック
  const restrictedPrefectures = ['沖縄県', '鹿児島県'];
  return Promise.resolve(!restrictedPrefectures.includes(address.prefecture));
}

Express.jsとの統合

import express, { Request, Response, NextFunction } from 'express';
import Joi from 'joi';

const app = express();
app.use(express.json());

// 型安全なバリデーションミドルウェア
interface TypedRequest<T> extends Request {
  validatedBody: T;
  validatedQuery: any;
  validatedParams: any;
}

function validateBody<T>(schema: Joi.ObjectSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    const { error, value } = schema.validate(req.body, {
      abortEarly: false,
      allowUnknown: false,
      stripUnknown: true
    });
    
    if (error) {
      const errors = error.details.map(detail => ({
        field: detail.path.join('.'),
        message: detail.message,
        value: detail.context?.value
      }));
      
      return res.status(400).json({
        success: false,
        message: 'バリデーションエラー',
        errors
      });
    }
    
    (req as TypedRequest<T>).validatedBody = value;
    next();
  };
}

function validateQuery<T>(schema: Joi.ObjectSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    const { error, value } = schema.validate(req.query, {
      abortEarly: false,
      allowUnknown: true,
      stripUnknown: true
    });
    
    if (error) {
      return res.status(400).json({
        success: false,
        message: 'クエリパラメータエラー',
        errors: error.details.map(d => d.message)
      });
    }
    
    (req as TypedRequest<T>).validatedQuery = value;
    next();
  };
}

// APIスキーマの定義
const CreateUserBodySchema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required(),
  profile: Joi.object({
    firstName: Joi.string().required(),
    lastName: Joi.string().required(),
    age: Joi.number().integer().min(18).max(120).optional()
  }).optional()
});

const GetUsersQuerySchema = Joi.object({
  page: Joi.number().integer().min(1).default(1),
  limit: Joi.number().integer().min(1).max(100).default(10),
  sort: Joi.string().valid('username', 'email', 'createdAt').default('createdAt'),
  order: Joi.string().valid('asc', 'desc').default('desc'),
  search: Joi.string().min(2).optional()
});

interface CreateUserBody {
  username: string;
  email: string;
  password: string;
  profile?: {
    firstName: string;
    lastName: string;
    age?: number;
  };
}

interface GetUsersQuery {
  page: number;
  limit: number;
  sort: 'username' | 'email' | 'createdAt';
  order: 'asc' | 'desc';
  search?: string;
}

// ルートハンドラー
app.post('/api/users', 
  validateBody<CreateUserBody>(CreateUserBodySchema),
  (req: TypedRequest<CreateUserBody>, res: Response) => {
    // req.validatedBodyは型安全
    const user = req.validatedBody;
    console.log(`新しいユーザー: ${user.username} (${user.email})`);
    
    // ユーザー作成ロジック
    res.status(201).json({
      success: true,
      message: 'ユーザーが正常に作成されました',
      data: {
        id: Date.now(),
        username: user.username,
        email: user.email,
        profile: user.profile,
        createdAt: new Date()
      }
    });
  }
);

app.get('/api/users',
  validateQuery<GetUsersQuery>(GetUsersQuerySchema),
  (req: TypedRequest<GetUsersQuery>, res: Response) => {
    // req.validatedQueryは型安全
    const query = req.validatedQuery;
    console.log(`ユーザー取得: page=${query.page}, limit=${query.limit}`);
    
    // ユーザー取得ロジック
    res.json({
      success: true,
      data: [],
      pagination: {
        page: query.page,
        limit: query.limit,
        total: 0,
        totalPages: 0
      }
    });
  }
);

// エラーハンドリングミドルウェア
app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
  if (error instanceof Joi.ValidationError) {
    return res.status(400).json({
      success: false,
      message: 'バリデーションエラー',
      errors: error.details.map(detail => detail.message)
    });
  }
  
  console.error('予期しないエラー:', error);
  res.status(500).json({
    success: false,
    message: 'サーバーエラー'
  });
});

app.listen(3000, () => {
  console.log('サーバーがポート3000で起動しました');
});

NestJSとの統合

// NestJSカスタムパイプ
import { Injectable, PipeTransform, BadRequestException } from '@nestjs/common';
import Joi from 'joi';

@Injectable()
export class JoiValidationPipe<T> implements PipeTransform<any, T> {
  constructor(private schema: Joi.ObjectSchema) {}

  transform(value: any): T {
    const { error, value: validatedValue } = this.schema.validate(value, {
      abortEarly: false,
      allowUnknown: false,
      stripUnknown: true
    });

    if (error) {
      const errorMessages = error.details.map(detail => ({
        field: detail.path.join('.'),
        message: detail.message
      }));

      throw new BadRequestException({
        message: 'バリデーションエラー',
        errors: errorMessages
      });
    }

    return validatedValue;
  }
}

// DTOクラスとスキーマ
export class CreateUserDto {
  username!: string;
  email!: string;
  password!: string;
  profile?: {
    firstName: string;
    lastName: string;
    age?: number;
  };
}

export const CreateUserSchema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required(),
  profile: Joi.object({
    firstName: Joi.string().required(),
    lastName: Joi.string().required(),
    age: Joi.number().integer().min(18).max(120).optional()
  }).optional()
});

// コントローラーでの使用
import { Controller, Post, Body, UsePipes } from '@nestjs/common';

@Controller('users')
export class UsersController {
  @Post()
  @UsePipes(new JoiValidationPipe<CreateUserDto>(CreateUserSchema))
  async createUser(@Body() createUserDto: CreateUserDto) {
    // createUserDtoは型安全で既にバリデーション済み
    console.log('新しいユーザー:', createUserDto);
    
    // ユーザー作成ロジック
    return {
      success: true,
      data: createUserDto
    };
  }
}

ユーティリティクラス

// 汎用バリデーションユーティリティ
export class ValidationService {
  static validate<T>(
    schema: Joi.ObjectSchema,
    data: unknown,
    options: Joi.ValidationOptions = {}
  ): { success: true; data: T } | { success: false; errors: ValidationError[] } {
    const defaultOptions: Joi.ValidationOptions = {
      abortEarly: false,
      allowUnknown: false,
      stripUnknown: true,
      ...options
    };

    const result = schema.validate(data, defaultOptions);

    if (result.error) {
      return {
        success: false,
        errors: this.formatErrors(result.error)
      };
    }

    return {
      success: true,
      data: result.value as T
    };
  }

  static async validateAsync<T>(
    schema: Joi.ObjectSchema,
    data: unknown,
    options: Joi.ValidationOptions = {}
  ): Promise<T> {
    const defaultOptions: Joi.ValidationOptions = {
      abortEarly: false,
      allowUnknown: false,
      stripUnknown: true,
      ...options
    };

    try {
      const result = await schema.validateAsync(data, defaultOptions);
      return result as T;
    } catch (error) {
      if (error instanceof Joi.ValidationError) {
        throw new ValidationException(this.formatErrors(error));
      }
      throw error;
    }
  }

  private static formatErrors(error: Joi.ValidationError): ValidationError[] {
    return error.details.map(detail => ({
      field: detail.path.join('.'),
      message: detail.message,
      value: detail.context?.value,
      type: detail.type
    }));
  }
}

interface ValidationError {
  field: string;
  message: string;
  value?: any;
  type: string;
}

export class ValidationException extends Error {
  constructor(public errors: ValidationError[]) {
    super('バリデーションエラー');
    this.name = 'ValidationException';
  }
}

// 型安全なバリデータークラス
export class TypedValidator<T> {
  constructor(private schema: Joi.ObjectSchema) {}

  validate(data: unknown): { success: true; data: T } | { success: false; errors: string[] } {
    return ValidationService.validate<T>(this.schema, data);
  }

  async validateAsync(data: unknown): Promise<T> {
    return ValidationService.validateAsync<T>(this.schema, data);
  }

  partial(): TypedValidator<Partial<T>> {
    const partialSchema = this.schema.fork(
      Object.keys(this.schema.describe().keys),
      (schema) => schema.optional()
    );
    return new TypedValidator<Partial<T>>(partialSchema);
  }

  pick<K extends keyof T>(keys: K[]): TypedValidator<Pick<T, K>> {
    const pickedSchema = this.schema.fork(
      keys as string[],
      (schema) => schema
    );
    return new TypedValidator<Pick<T, K>>(pickedSchema);
  }
}

// 使用例
const userValidator = new TypedValidator<User>(UserSchema);

// 部分バリデーション(更新時など)
const partialUserValidator = userValidator.partial();

// 特定フィールドのみバリデーション
const loginValidator = userValidator.pick(['email', 'password']);

比較・代替手段

類似ライブラリとの比較

  • Zod: TypeScript-firstでより良い型推論、モダンなAPI
  • Yup: より軽量だが機能が限定的
  • io-ts: 関数型プログラミング指向、学習曲線が急
  • class-validator: デコレーター基盤、NestJSとの統合が優秀
  • Superstruct: シンプルだが企業レベルの機能に制限

Joiを選ぶべき場面

  • Node.jsサーバーサイドアプリケーション開発
  • 複雑なバリデーション要件がある場合
  • Express.js/NestJSとの統合が必要
  • 豊富なバリデーションルールが必要
  • 長期的な安定性と実績を重視
  • 非同期バリデーションが必要

TypeScriptでの注意点

  • 型推論サポートが限定的(手動で型定義が必要)
  • スキーマと型の同期が開発者の責任
  • ブラウザでの使用には制限がある
  • バンドルサイズが大きめ

学習リソース

まとめ

JoiはTypeScriptプロジェクトにおけるサーバーサイドバリデーションの標準的な選択肢です。豊富な機能セット、Express.js/NestJSとの優れた統合、長年の実績により、企業レベルのNode.jsアプリケーション開発で信頼されています。TypeScriptとの組み合わせにより、型安全性を保ちながら包括的なデータ検証を実現できます。特に、複雑なAPIバリデーション、条件付きバリデーション、カスタムビジネスロジックの検証が必要な場面で威力を発揮します。