Joi

バリデーションライブラリJavaScriptNode.jsスキーマサーバーサイドAPI

GitHub概要

hapijs/joi

The most powerful data validation library for JS

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

トピックス

hapijavascriptschemavalidation

スター履歴

hapijs/joi Star History
データ取得日時: 2025/7/18 07:04

ライブラリ

Joi

概要

Joiは「The most powerful data validation library for JS」として開発されたNode.js向けの強力で成熟したスキーマバリデーションライブラリです。豊富なバリデーションルールと柔軟なカスタマイズ機能により、複雑なバリデーション要件にも対応可能。長年の実績と豊富な機能でサーバーサイド開発において信頼される存在として、特に複雑なAPIバリデーションで選ばれることが多い老舗ライブラリです。Express.jsとの統合により堅牢なWebアプリケーション開発を支援します。

詳細

Joi 17.13.3は2025年現在の最新版で、13,109以上のnpmプロジェクトで使用されているNode.jsエコシステムの標準的なバリデーションライブラリです。スキーマ記述言語として包括的かつ強力な機能を提供し、文字列、数値、ブール値、配列、オブジェクト、日付など豊富な型のバリデーションが可能。Express.jsのミドルウェアとしての統合により、リクエストレベルでのデータ検証パイプラインを構築し、フロントエンドからの信頼できないデータを安全に処理します。

主な特徴

  • 包括的なバリデーション: 文字列から複雑なオブジェクトまで幅広い型対応
  • 豊富なルールセット: 詳細な制約条件とカスタムバリデーション機能
  • Express.js統合: ミドルウェアとしての簡単な統合とエラーハンドリング
  • 柔軟なスキーマ定義: ネストしたオブジェクトと条件付きバリデーション
  • 詳細なエラー報告: 具体的で分かりやすいバリデーションエラーメッセージ
  • 高いカスタマイズ性: プラグイン機能とカスタムバリデーター対応

メリット・デメリット

メリット

  • Node.jsエコシステムでの長年の実績と安定性
  • 複雑なAPIバリデーション要件に対応する豊富な機能
  • Express.jsとの優れた統合とミドルウェア対応
  • 包括的な公式ドキュメントと充実したコミュニティサポート
  • 企業レベルのサーバーサイドアプリケーションでの採用実績
  • 詳細で分かりやすいエラーメッセージとデバッグ支援

デメリット

  • ブラウザ(クライアントサイド)での使用に制限
  • バンドルサイズが大きめ(軽量なバリデーションには向かない)
  • TypeScriptでの型推論サポートが限定的
  • モダンなJavaScriptフレームワークとの統合で追加設定が必要
  • 関数型プログラミングスタイルに不向き
  • パフォーマンス重視のアプリケーションではオーバーヘッド

参考ページ

書き方の例

インストールと基本セットアップ

# Joiのインストール
npm install joi

# TypeScript用の型定義(コミュニティ製)
npm install @types/joi

# Yarnを使用する場合
yarn add joi
yarn add -D @types/joi

基本的なスキーマ定義とバリデーション

const Joi = require('joi');

// 基本的なスキーマ定義
const userSchema = Joi.object({
    username: Joi.string()
        .alphanum()
        .min(3)
        .max(30)
        .required(),
    
    email: Joi.string()
        .email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } })
        .required(),
    
    password: Joi.string()
        .pattern(new RegExp('^[a-zA-Z0-9]{3,30}$'))
        .required(),
    
    age: Joi.number()
        .integer()
        .min(18)
        .max(100),
    
    birth_year: Joi.number()
        .integer()
        .min(1900)
        .max(2013),
    
    access_token: [
        Joi.string(),
        Joi.number()
    ]
});

// データのバリデーション
const userData = {
    username: 'john123',
    email: '[email protected]',
    password: 'mypassword123',
    age: 25
};

// 同期バリデーション
const { error, value } = userSchema.validate(userData);

if (error) {
    console.log('バリデーションエラー:', error.details);
} else {
    console.log('バリデーション成功:', value);
}

// 非同期バリデーション
async function validateUserAsync(data) {
    try {
        const value = await userSchema.validateAsync(data);
        console.log('バリデーション成功:', value);
        return value;
    } catch (err) {
        console.log('バリデーションエラー:', err.details);
        throw err;
    }
}

// プリミティブ型のバリデーション例
const stringSchema = Joi.string().min(2).max(10);
const numberSchema = Joi.number().positive();
const booleanSchema = Joi.boolean();
const dateSchema = Joi.date().iso();

console.log(stringSchema.validate('hello')); // 成功
console.log(numberSchema.validate(42)); // 成功
console.log(booleanSchema.validate(true)); // 成功

高度なバリデーションルールとカスタムバリデーター

// 複雑なオブジェクトスキーマ
const productSchema = Joi.object({
    name: Joi.string()
        .min(1)
        .max(100)
        .required()
        .description('商品名'),
    
    price: Joi.number()
        .positive()
        .precision(2)
        .required()
        .description('価格(小数点2桁まで)'),
    
    category: Joi.string()
        .valid('electronics', 'clothing', 'books', 'food')
        .required(),
    
    tags: Joi.array()
        .items(Joi.string().min(1))
        .min(1)
        .max(10)
        .unique(),
    
    specifications: Joi.object({
        weight: Joi.number().positive().unit('kg'),
        dimensions: Joi.object({
            width: Joi.number().positive(),
            height: Joi.number().positive(),
            depth: Joi.number().positive()
        }),
        color: Joi.string().hex(),
        material: Joi.string().optional()
    }).optional(),
    
    inStock: Joi.boolean().default(true),
    
    metadata: Joi.object().pattern(
        Joi.string(),
        [Joi.string(), Joi.number(), Joi.boolean()]
    ).optional()
});

// 条件付きバリデーション
const conditionalSchema = Joi.object({
    type: Joi.string().valid('user', 'admin').required(),
    
    username: Joi.string().when('type', {
        is: 'admin',
        then: Joi.string().min(5).required(),
        otherwise: Joi.string().min(3).required()
    }),
    
    permissions: Joi.array().when('type', {
        is: 'admin',
        then: Joi.array().items(Joi.string()).min(1).required(),
        otherwise: Joi.forbidden()
    })
});

// カスタムバリデーター
const customSchema = Joi.object({
    email: Joi.string().custom((value, helpers) => {
        // カスタムメールバリデーション
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!emailRegex.test(value)) {
            return helpers.error('any.invalid');
        }
        
        // 禁止ドメインのチェック
        const forbiddenDomains = ['spam.com', 'temp.mail'];
        const domain = value.split('@')[1];
        if (forbiddenDomains.includes(domain)) {
            return helpers.message('このドメインは使用できません');
        }
        
        return value.toLowerCase();
    }, 'カスタムメールバリデーション'),
    
    phoneNumber: Joi.string().custom((value, helpers) => {
        // 日本の電話番号形式をチェック
        const phoneRegex = /^(\+81|0)\d{1,4}-\d{1,4}-\d{4}$/;
        if (!phoneRegex.test(value)) {
            return helpers.error('string.pattern', { value });
        }
        return value;
    }),
    
    password: Joi.string().custom((value, helpers) => {
        // 複雑なパスワード要件
        if (value.length < 8) {
            return helpers.message('パスワードは8文字以上である必要があります');
        }
        if (!/[A-Z]/.test(value)) {
            return helpers.message('パスワードには大文字を含める必要があります');
        }
        if (!/[a-z]/.test(value)) {
            return helpers.message('パスワードには小文字を含める必要があります');
        }
        if (!/\d/.test(value)) {
            return helpers.message('パスワードには数字を含める必要があります');
        }
        if (!/[!@#$%^&*(),.?":{}|<>]/.test(value)) {
            return helpers.message('パスワードには特殊文字を含める必要があります');
        }
        return value;
    })
});

// 配列の詳細バリデーション
const arraySchema = Joi.object({
    numbers: Joi.array()
        .items(Joi.number().integer().min(1).max(100))
        .min(1)
        .max(10)
        .unique()
        .sort(),
    
    users: Joi.array().items(
        Joi.object({
            name: Joi.string().required(),
            role: Joi.string().valid('user', 'admin').default('user')
        })
    ).has(Joi.object({ role: 'admin' })) // 少なくとも1人のadminが必要
});

フレームワーク統合(Express、NestJS、Fastify等)

// Express.jsとの統合例
const express = require('express');
const Joi = require('joi');

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

// バリデーションミドルウェアの作成
const validate = (schema, property = 'body') => {
    return (req, res, next) => {
        const { error, value } = schema.validate(req[property], {
            abortEarly: false, // 全てのエラーを取得
            allowUnknown: true, // 未知のプロパティを許可
            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: errors
            });
        }
        
        // バリデーション済みデータで置き換え
        req[property] = value;
        next();
    };
};

// ユーザー作成API
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()
});

app.post('/api/users', validate(createUserSchema), (req, res) => {
    // req.bodyは既にバリデーション済み
    console.log('検証済みユーザーデータ:', req.body);
    
    // ユーザー作成ロジック
    res.json({
        success: true,
        message: 'ユーザーが正常に作成されました',
        data: req.body
    });
});

// クエリパラメータのバリデーション
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('name', 'email', 'created_at').default('created_at'),
    order: Joi.string().valid('asc', 'desc').default('desc'),
    search: Joi.string().min(2).optional()
});

app.get('/api/users', validate(getUsersQuerySchema, 'query'), (req, res) => {
    console.log('検証済みクエリパラメータ:', req.query);
    
    // ユーザー取得ロジック
    res.json({
        success: true,
        data: [],
        pagination: {
            page: req.query.page,
            limit: req.query.limit,
            total: 0
        }
    });
});

// NestJSとの統合例(TypeScript)
/*
import { Injectable, PipeTransform, BadRequestException } from '@nestjs/common';
import * as Joi from 'joi';

@Injectable()
export class JoiValidationPipe implements PipeTransform {
    constructor(private schema: Joi.ObjectSchema) {}

    transform(value: any) {
        const { error, value: validatedValue } = this.schema.validate(value);
        if (error) {
            throw new BadRequestException('バリデーションエラー');
        }
        return validatedValue;
    }
}

// 使用例
@Post('users')
@UsePipes(new JoiValidationPipe(createUserSchema))
createUser(@Body() createUserDto: any) {
    // バリデーション済みデータの使用
    return this.userService.create(createUserDto);
}
*/

// Fastifyとの統合例
/*
const fastify = require('fastify')({ logger: true });

const userSchema = {
    body: {
        type: 'object',
        properties: {
            username: { type: 'string', minLength: 3, maxLength: 30 },
            email: { type: 'string', format: 'email' },
            password: { type: 'string', minLength: 8 }
        },
        required: ['username', 'email', 'password']
    }
};

fastify.post('/users', { schema: userSchema }, async (request, reply) => {
    // request.bodyは自動的にバリデーション済み
    return { success: true, data: request.body };
});
*/

エラーハンドリングとカスタムエラーメッセージ

// 詳細なエラーハンドリング
const detailedSchema = Joi.object({
    name: Joi.string().min(2).max(50).required().messages({
        'string.base': '名前は文字列である必要があります',
        'string.empty': '名前を入力してください',
        'string.min': '名前は{#limit}文字以上である必要があります',
        'string.max': '名前は{#limit}文字以下である必要があります',
        'any.required': '名前は必須項目です'
    }),
    
    email: Joi.string().email().required().messages({
        'string.email': '有効なメールアドレスを入力してください',
        'any.required': 'メールアドレスは必須項目です'
    }),
    
    age: Joi.number().integer().min(0).max(150).messages({
        'number.base': '年齢は数値である必要があります',
        'number.integer': '年齢は整数である必要があります',
        'number.min': '年齢は{#limit}歳以上である必要があります',
        'number.max': '年齢は{#limit}歳以下である必要があります'
    })
});

// バリデーションエラーの詳細処理
function handleValidationError(error) {
    const errors = {};
    
    error.details.forEach(detail => {
        const field = detail.path.join('.');
        errors[field] = {
            message: detail.message,
            value: detail.context.value,
            type: detail.type
        };
    });
    
    return {
        success: false,
        message: 'バリデーションエラーが発生しました',
        errors: errors,
        errorCount: error.details.length
    };
}

// 使用例
const testData = {
    name: 'A', // 短すぎる
    email: 'invalid-email', // 無効なメール
    age: -5 // 負の値
};

const { error } = detailedSchema.validate(testData, { abortEarly: false });
if (error) {
    const errorResponse = handleValidationError(error);
    console.log(JSON.stringify(errorResponse, null, 2));
}

// カスタムエラー処理クラス
class ValidationService {
    static validate(schema, data, options = {}) {
        const defaultOptions = {
            abortEarly: false,
            allowUnknown: false,
            stripUnknown: true,
            ...options
        };
        
        const result = schema.validate(data, defaultOptions);
        
        if (result.error) {
            return {
                isValid: false,
                data: null,
                errors: this.formatErrors(result.error),
                summary: this.getErrorSummary(result.error)
            };
        }
        
        return {
            isValid: true,
            data: result.value,
            errors: null,
            summary: null
        };
    }
    
    static formatErrors(error) {
        return error.details.reduce((acc, detail) => {
            const fieldPath = detail.path.join('.');
            acc[fieldPath] = {
                message: detail.message,
                value: detail.context?.value,
                type: detail.type,
                constraint: detail.context?.limit || detail.context?.valids
            };
            return acc;
        }, {});
    }
    
    static getErrorSummary(error) {
        const totalErrors = error.details.length;
        const fieldCount = new Set(error.details.map(d => d.path[0])).size;
        const errorTypes = error.details.map(d => d.type);
        
        return {
            totalErrors,
            affectedFields: fieldCount,
            mostCommonError: this.getMostCommon(errorTypes),
            hasRequiredErrors: errorTypes.includes('any.required'),
            hasTypeErrors: errorTypes.some(type => type.includes('.base'))
        };
    }
    
    static getMostCommon(array) {
        const counts = array.reduce((acc, val) => {
            acc[val] = (acc[val] || 0) + 1;
            return acc;
        }, {});
        
        return Object.keys(counts).reduce((a, b) => 
            counts[a] > counts[b] ? a : b
        );
    }
}

// ValidationServiceの使用例
const userValidation = ValidationService.validate(detailedSchema, testData);
console.log('バリデーション結果:', userValidation);

型安全性とTypeScript統合

// TypeScript用の型定義(型安全なJoi使用)
import * as Joi from 'joi';

// スキーマから型を推論するヘルパー
type JoiSchemaType<T> = T extends Joi.ObjectSchema<infer U> ? U : never;

// ユーザースキーマの定義
const userSchemaTyped = Joi.object({
    id: Joi.number().integer().positive().required(),
    username: Joi.string().alphanum().min(3).max(30).required(),
    email: Joi.string().email().required(),
    isActive: Joi.boolean().default(true),
    profile: Joi.object({
        firstName: Joi.string().required(),
        lastName: Joi.string().required(),
        age: Joi.number().integer().min(18).optional(),
        avatar: Joi.string().uri().optional()
    }).optional(),
    roles: Joi.array().items(Joi.string().valid('admin', 'user', 'moderator')).default(['user']),
    metadata: Joi.object().pattern(Joi.string(), Joi.any()).optional(),
    createdAt: Joi.date().default(() => new Date()),
    lastLogin: Joi.date().optional()
});

// 型の定義
interface User {
    id: number;
    username: string;
    email: string;
    isActive: boolean;
    profile?: {
        firstName: string;
        lastName: string;
        age?: number;
        avatar?: string;
    };
    roles: Array<'admin' | 'user' | 'moderator'>;
    metadata?: { [key: string]: any };
    createdAt: Date;
    lastLogin?: Date;
}

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

// API関数での使用例
async function createUser(userData: unknown): Promise<User> {
    const validation = validateUser(userData);
    
    if (!validation.isValid) {
        throw new Error(`バリデーションエラー: ${validation.error.message}`);
    }
    
    // validation.dataはUser型として型安全
    const user = validation.data;
    console.log(`ユーザー作成: ${user.username} (${user.email})`);
    
    // データベース保存ロジック
    return user;
}

// 汎用バリデーション関数
class TypedValidator<T> {
    constructor(private schema: Joi.ObjectSchema<T>) {}
    
    validate(data: unknown): { success: true; data: T } | { success: false; errors: string[] } {
        const result = this.schema.validate(data, { abortEarly: false });
        
        if (result.error) {
            return {
                success: false,
                errors: result.error.details.map(detail => detail.message)
            };
        }
        
        return {
            success: true,
            data: result.value as T
        };
    }
    
    async validateAsync(data: unknown): Promise<T> {
        const result = await this.schema.validateAsync(data);
        return result as T;
    }
}

// 型安全なバリデーターの使用
const userValidator = new TypedValidator<User>(userSchemaTyped);

const testUserData = {
    id: 1,
    username: 'johndoe',
    email: '[email protected]',
    profile: {
        firstName: 'John',
        lastName: 'Doe',
        age: 30
    }
};

const validationResult = userValidator.validate(testUserData);
if (validationResult.success) {
    // validationResult.dataはUser型として型安全
    console.log(`検証済みユーザー: ${validationResult.data.username}`);
} else {
    console.log('エラー:', validationResult.errors);
}

// 複雑なネストしたオブジェクトの型安全バリデーション
interface APIResponse<T> {
    success: boolean;
    data: T;
    message: string;
    timestamp: Date;
}

function createAPIResponseSchema<T>(dataSchema: Joi.Schema<T>) {
    return Joi.object({
        success: Joi.boolean().required(),
        data: dataSchema.required(),
        message: Joi.string().required(),
        timestamp: Joi.date().required()
    });
}

const userAPIResponseSchema = createAPIResponseSchema(userSchemaTyped);
type UserAPIResponse = APIResponse<User>;

// 実用的な例:Express.jsでの型安全な使用
/*
import { Request, Response, NextFunction } from 'express';

interface TypedRequest<T> extends Request {
    validatedBody: T;
}

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

// 使用例
app.post('/users', validateBody(userSchemaTyped), (req: TypedRequest<User>, res: Response) => {
    // req.validatedBodyはUser型として型安全
    const user = req.validatedBody;
    console.log(`新しいユーザー: ${user.username}`);
    res.json({ success: true, data: user });
});
*/