Valibot

バリデーションライブラリTypeScriptスキーマモジュラー軽量型安全性

ライブラリ

Valibot

概要

Valibotは「モジュラーで型安全なスキーマライブラリ」として開発された、次世代TypeScriptバリデーションライブラリです。Zod、Ajv、Joi、Yupと類似の機能を提供しながら、革新的なモジュラー設計により劇的なバンドルサイズ削減を実現します。最小700バイト未満から開始し、tree shakingとコード分割により、Zodと比較して最大95%のバンドルサイズ削減が可能です。2025年現在、モダンWebアプリケーションのパフォーマンス要求に応える最先端のバリデーションソリューションです。

詳細

Valibot 0.30系は2025年現在の最新版で、外部依存関係なしでJavaScript環境で動作します。従来の巨大な関数とメソッドチェーンに頼らず、小さく独立した関数の組み合わせによるAPI設計を採用。100%のテストカバレッジにより信頼性を保証し、React、Vue、Svelte、NestJS、DrizzleORM等の幅広いエコシステムサポートを提供します。パイプライン形式のバリデーション(最大20アクション)により、チェックと変換を柔軟に組み合わせ可能です。

主な特徴

  • 極小バンドルサイズ: tree shakingにより700バイト未満から開始
  • 完全な型安全性: TypeScript静的型推論との完全統合
  • モジュラー設計: 必要な機能のみをインポートする革新的アーキテクチャ
  • パイプライン形式バリデーション: アクションの組み合わせによる柔軟な処理
  • ゼロ依存関係: 外部ライブラリに依存しない純粋な実装
  • 豊富なエコシステム: 主要フレームワークとの幅広い統合

メリット・デメリット

メリット

  • Zodより最大95%小さいバンドルサイズ
  • モダンWebアプリケーションに最適化されたパフォーマンス
  • 100%テストカバレッジによる高い信頼性
  • TypeScriptファーストの開発体験
  • tree shakingによる完全な最適化
  • 主要フレームワークエコシステムとの統合

デメリット

  • 比較的新しいライブラリで情報が限定的
  • Zodに比べて採用事例がまだ少ない
  • 学習コストがやや高い(新しいパイプライン概念)
  • エコシステムがまだ成長中
  • 複雑なスキーマでの設計パターンが発展途上
  • プロダクション使用時の長期サポート実績が少ない

参考ページ

書き方の例

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

# Valibotのインストール
npm install valibot
yarn add valibot
pnpm add valibot
bun add valibot

# TypeScript 4.7以上が必要
# 依存関係なしの軽量ライブラリ

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

import { 
  object, 
  string, 
  number, 
  email, 
  minLength, 
  maxLength, 
  parse, 
  safeParse,
  pipe,
  InferInput,
  InferOutput
} from 'valibot';

// 基本的なスキーマ定義(モジュラー形式)
const UserSchema = object({
  name: pipe(
    string(),
    minLength(2, '名前は2文字以上である必要があります'),
    maxLength(50, '名前は50文字以下である必要があります')
  ),
  age: pipe(
    number(),
    minValue(0, '年齢は0以上である必要があります'),
    maxValue(150, '年齢は150以下である必要があります')
  ),
  email: pipe(
    string(),
    email('有効なメールアドレスを入力してください')
  )
});

// 型推論(入力型と出力型)
type UserInput = InferInput<typeof UserSchema>;
type UserOutput = InferOutput<typeof UserSchema>;

// データのバリデーション
const userData = {
  name: '田中太郎',
  age: 30,
  email: '[email protected]'
};

try {
  // parse: 失敗時は例外をthrow
  const validUser = parse(UserSchema, userData);
  console.log('バリデーション成功:', validUser);
} catch (error) {
  console.error('バリデーションエラー:', error);
}

// safeParse: 失敗時も例外を投げない
const result = safeParse(UserSchema, userData);

if (result.success) {
  console.log('バリデーション成功:', result.output);
} else {
  console.log('バリデーション失敗:');
  result.issues.forEach(issue => {
    console.log(`${issue.path?.join('.')}: ${issue.message}`);
  });
}

// プリミティブ型のバリデーション
import { boolean, date } from 'valibot';

const stringSchema = string();
const numberSchema = number();
const booleanSchema = boolean();
const dateSchema = date();

console.log(parse(stringSchema, "Hello")); // "Hello"
console.log(parse(numberSchema, 42)); // 42
console.log(parse(booleanSchema, true)); // true
console.log(parse(dateSchema, new Date())); // Date object

高度なバリデーションとパイプライン処理

import {
  object,
  string,
  number,
  array,
  optional,
  nullable,
  pipe,
  regex,
  transform,
  custom,
  union,
  literal,
  minLength,
  maxLength,
  minValue,
  maxValue,
  email,
  url
} from 'valibot';

// 複雑なパイプラインバリデーション
const PasswordSchema = pipe(
  string(),
  minLength(8, 'パスワードは8文字以上である必要があります'),
  maxLength(128, 'パスワードは128文字以下である必要があります'),
  regex(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
    'パスワードは大文字、小文字、数字、特殊文字を含む必要があります'
  )
);

// カスタムバリデーション
const EvenNumberSchema = pipe(
  number(),
  custom(
    (value) => value % 2 === 0,
    '偶数である必要があります'
  )
);

// 変換を含むパイプライン
const TrimmedStringSchema = pipe(
  string(),
  transform((value) => value.trim()),
  minLength(1, 'トリム後に空文字は許可されません')
);

// Union型(複数の型の選択肢)
const StatusSchema = union([
  literal('pending'),
  literal('approved'),
  literal('rejected')
]);

// Optional とNullable
const UserProfileSchema = object({
  id: string(),
  name: string(),
  bio: optional(pipe(string(), maxLength(500))),
  avatar: nullable(pipe(string(), url())),
  age: optional(pipe(number(), minValue(13), maxValue(120))),
  status: StatusSchema
});

// 配列のバリデーション
const TagsSchema = pipe(
  array(pipe(string(), minLength(1), maxLength(20))),
  minLength(1, '最低1つのタグが必要です'),
  maxLength(10, '最大10個のタグまで許可されます')
);

// ネストしたオブジェクト
const AddressSchema = object({
  street: pipe(string(), minLength(5)),
  city: string(),
  postalCode: pipe(
    string(),
    regex(/^\d{3}-\d{4}$/, '郵便番号は123-4567の形式で入力してください')
  ),
  country: pipe(string(), minLength(2))
});

const ComplexUserSchema = object({
  personal: object({
    firstName: pipe(string(), minLength(1)),
    lastName: pipe(string(), minLength(1)),
    email: pipe(string(), email())
  }),
  address: AddressSchema,
  tags: TagsSchema,
  preferences: object({
    newsletter: boolean(),
    theme: union([literal('light'), literal('dark'), literal('auto')])
  })
});

// 複雑なデータのバリデーション
const complexData = {
  personal: {
    firstName: '太郎',
    lastName: '田中',
    email: '[email protected]'
  },
  address: {
    street: '渋谷区道玄坂1-2-3',
    city: '東京',
    postalCode: '150-0043',
    country: 'Japan'
  },
  tags: ['developer', 'typescript', 'javascript'],
  preferences: {
    newsletter: true,
    theme: 'dark' as const
  }
};

const complexResult = safeParse(ComplexUserSchema, complexData);

if (complexResult.success) {
  console.log('複雑なバリデーション成功:', complexResult.output);
} else {
  console.log('複雑なバリデーションエラー:');
  complexResult.issues.forEach(issue => {
    console.log(`フィールド: ${issue.path?.join('.')}`);
    console.log(`メッセージ: ${issue.message}`);
  });
}

フレームワーク統合(React、Vue、NestJS等)

// React Hook Formとの統合
import { useForm } from 'react-hook-form';
import { valibotResolver } from '@hookform/resolvers/valibot';
import { object, string, number, pipe, minLength, email, minValue } from 'valibot';

const FormSchema = object({
  name: pipe(string(), minLength(1, '名前は必須です')),
  email: pipe(string(), email('有効なメールアドレスを入力してください')),
  age: pipe(number(), minValue(18, '18歳以上である必要があります'))
});

function UserForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: valibotResolver(FormSchema)
  });

  const onSubmit = (data: InferOutput<typeof FormSchema>) => {
    console.log('送信データ:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} placeholder="名前" />
      {errors.name && <span>{errors.name.message}</span>}
      
      <input {...register('email')} placeholder="メールアドレス" type="email" />
      {errors.email && <span>{errors.email.message}</span>}
      
      <input {...register('age', { valueAsNumber: true })} placeholder="年齢" type="number" />
      {errors.age && <span>{errors.age.message}</span>}
      
      <button type="submit">送信</button>
    </form>
  );
}

// Express.jsでのミドルウェア例
import express from 'express';
import { object, string, pipe, minLength, email } from 'valibot';

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

const CreateUserSchema = object({
  name: pipe(string(), minLength(1)),
  email: pipe(string(), email()),
  password: pipe(string(), minLength(8))
});

// バリデーションミドルウェア
const validateRequest = (schema: any) => {
  return (req: express.Request, res: express.Response, next: express.NextFunction) => {
    const result = safeParse(schema, req.body);
    
    if (!result.success) {
      return res.status(400).json({
        error: 'バリデーションエラー',
        issues: result.issues.map(issue => ({
          field: issue.path?.join('.'),
          message: issue.message
        }))
      });
    }
    
    req.body = result.output; // 正規化されたデータを設定
    next();
  };
};

app.post('/users', validateRequest(CreateUserSchema), (req, res) => {
  res.json({ message: 'ユーザー作成成功', user: req.body });
});

// NestJS DTOとの統合例
import { Injectable, PipeTransform, BadRequestException } from '@nestjs/common';
import { BaseSchema, safeParse } from 'valibot';

@Injectable()
export class ValibotValidationPipe implements PipeTransform {
  constructor(private schema: BaseSchema<any, any, any>) {}

  transform(value: any) {
    const result = safeParse(this.schema, value);
    
    if (!result.success) {
      const errorMessages = result.issues.map(issue => 
        `${issue.path?.join('.')}: ${issue.message}`
      );
      throw new BadRequestException({
        message: 'バリデーションエラー',
        errors: errorMessages
      });
    }
    
    return result.output;
  }
}

// NestJS Controllerでの使用
import { Controller, Post, Body } from '@nestjs/common';

@Controller('users')
export class UsersController {
  @Post()
  async createUser(@Body(new ValibotValidationPipe(CreateUserSchema)) userData: InferOutput<typeof CreateUserSchema>) {
    return { message: 'ユーザーが作成されました', user: userData };
  }
}

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

import {
  custom,
  transform,
  pipe,
  string,
  number,
  object,
  array,
  forward,
  partialCheck,
  BaseSchema,
  BaseIssue
} from 'valibot';

// カスタムバリデーション関数
const isUniqueEmail = (email: string): boolean => {
  // 実際のアプリケーションでは、データベースをチェック
  const existingEmails = ['[email protected]', '[email protected]'];
  return !existingEmails.includes(email.toLowerCase());
};

// 非同期カスタムバリデーション(Valibotは同期のみだが、パターンを示す)
const EmailUniqueSchema = pipe(
  string(),
  email(),
  custom(
    (email) => isUniqueEmail(email),
    'このメールアドレスは既に使用されています'
  )
);

// 複雑な条件付きバリデーション
const ConditionalUserSchema = object({
  accountType: union([literal('personal'), literal('business')]),
  name: string(),
  companyName: optional(string()),
  taxId: optional(string())
});

// 全体のオブジェクトバリデーション
const BusinessAccountSchema = pipe(
  ConditionalUserSchema,
  partialCheck(
    [['accountType'], ['companyName'], ['taxId']],
    (input) => {
      if (input.accountType === 'business') {
        return !!(input.companyName && input.taxId);
      }
      return true;
    },
    'ビジネスアカウントでは会社名と税務IDが必要です'
  )
);

// データ変換を含むスキーマ
const UserInputSchema = object({
  name: pipe(
    string(),
    transform(name => name.trim()),
    transform(name => name.replace(/\s+/g, ' ')), // 複数空白を単一空白に
    minLength(1)
  ),
  email: pipe(
    string(),
    transform(email => email.toLowerCase()),
    email()
  ),
  tags: pipe(
    string(),
    transform(tags => tags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0)),
    transform(tags => [...new Set(tags)]) // 重複除去
  )
});

// 再帰的スキーマ(自己参照)
type Category = {
  id: string;
  name: string;
  parent?: Category;
  children: Category[];
};

const CategorySchema: BaseSchema<any, Category, BaseIssue<unknown>> = object({
  id: string(),
  name: string(),
  parent: optional(lazy(() => CategorySchema)),
  children: array(lazy(() => CategorySchema))
});

// パフォーマンス最適化されたスキーマ
const OptimizedSchema = object({
  // 基本的なバリデーションのみで高速
  id: string(),
  name: string(),
  // 複雑なバリデーションは必要な場合のみ
  email: conditional(
    (input) => typeof input === 'string' && input.includes('@'),
    pipe(string(), email()),
    string()
  )
});

// バリデーションエラーのカスタマイズ
const CustomErrorSchema = object({
  username: pipe(
    string(),
    minLength(3, 'ユーザー名は3文字以上必要です'),
    maxLength(20, 'ユーザー名は20文字以下である必要があります'),
    regex(
      /^[a-zA-Z0-9_]+$/,
      'ユーザー名は英数字とアンダースコアのみ使用可能です'
    ),
    custom(
      (username) => !['admin', 'root', 'system'].includes(username.toLowerCase()),
      'このユーザー名は予約されています'
    )
  )
});

// バリデーション結果の詳細解析
function analyzeValidationResult<T>(
  schema: BaseSchema<any, T, any>,
  data: unknown
): {
  isValid: boolean;
  data?: T;
  errorSummary?: {
    totalErrors: number;
    fieldErrors: Record<string, string[]>;
    globalErrors: string[];
  };
} {
  const result = safeParse(schema, data);
  
  if (result.success) {
    return {
      isValid: true,
      data: result.output
    };
  }
  
  const fieldErrors: Record<string, string[]> = {};
  const globalErrors: string[] = [];
  
  result.issues.forEach(issue => {
    if (issue.path && issue.path.length > 0) {
      const fieldPath = issue.path.join('.');
      if (!fieldErrors[fieldPath]) {
        fieldErrors[fieldPath] = [];
      }
      fieldErrors[fieldPath].push(issue.message);
    } else {
      globalErrors.push(issue.message);
    }
  });
  
  return {
    isValid: false,
    errorSummary: {
      totalErrors: result.issues.length,
      fieldErrors,
      globalErrors
    }
  };
}

// 使用例
const testData = {
  accountType: 'business' as const,
  name: '  山田 太郎  ',
  companyName: '',
  taxId: ''
};

const analysis = analyzeValidationResult(BusinessAccountSchema, testData);
console.log('バリデーション分析結果:', analysis);

バンドルサイズ最適化とパフォーマンス

// 必要な機能のみインポート(tree shaking最適化)
import { string, number, parse } from 'valibot';

// ミニマルなスキーマ(最小バンドルサイズ)
const MinimalSchema = object({
  id: string(),
  count: number()
});

// 機能別インポートによる最適化
import { 
  object, 
  string, 
  email,    // メール検証が必要な場合のみ
  url,      // URL検証が必要な場合のみ
  minLength // 長さ検証が必要な場合のみ
} from 'valibot';

// 条件付きインポートパターン(動的インポート)
const createAdvancedSchema = async () => {
  const { regex, transform, custom } = await import('valibot');
  
  return object({
    username: pipe(
      string(),
      transform(s => s.toLowerCase()),
      regex(/^[a-z0-9_]+$/),
      custom(s => s !== 'admin')
    )
  });
};

// パフォーマンス測定
function benchmarkValidation<T>(
  schema: BaseSchema<any, T, any>,
  data: unknown,
  iterations: number = 1000
): { averageTime: number; successRate: number } {
  let successCount = 0;
  const startTime = performance.now();
  
  for (let i = 0; i < iterations; i++) {
    const result = safeParse(schema, data);
    if (result.success) {
      successCount++;
    }
  }
  
  const endTime = performance.now();
  const averageTime = (endTime - startTime) / iterations;
  const successRate = successCount / iterations;
  
  return { averageTime, successRate };
}

// バッチバリデーション(大量データ処理)
function validateBatch<T>(
  schema: BaseSchema<any, T, any>,
  dataArray: unknown[]
): {
  valid: T[];
  invalid: { data: unknown; issues: any[] }[];
  summary: { total: number; validCount: number; invalidCount: number };
} {
  const valid: T[] = [];
  const invalid: { data: unknown; issues: any[] }[] = [];
  
  for (const data of dataArray) {
    const result = safeParse(schema, data);
    if (result.success) {
      valid.push(result.output);
    } else {
      invalid.push({ data, issues: result.issues });
    }
  }
  
  return {
    valid,
    invalid,
    summary: {
      total: dataArray.length,
      validCount: valid.length,
      invalidCount: invalid.length
    }
  };
}

// メモ化によるパフォーマンス最適化
const schemaCache = new Map();

function getCachedSchema(schemaKey: string, schemaFactory: () => any) {
  if (!schemaCache.has(schemaKey)) {
    schemaCache.set(schemaKey, schemaFactory());
  }
  return schemaCache.get(schemaKey);
}

// 使用例
const userSchema = getCachedSchema('user', () => 
  object({
    name: string(),
    email: pipe(string(), email())
  })
);

// ストリーミングバリデーション(リアルタイム処理)
class StreamValidator<T> {
  private schema: BaseSchema<any, T, any>;
  private onValid: (data: T) => void;
  private onInvalid: (data: unknown, issues: any[]) => void;

  constructor(
    schema: BaseSchema<any, T, any>,
    handlers: {
      onValid: (data: T) => void;
      onInvalid: (data: unknown, issues: any[]) => void;
    }
  ) {
    this.schema = schema;
    this.onValid = handlers.onValid;
    this.onInvalid = handlers.onInvalid;
  }

  process(data: unknown): void {
    const result = safeParse(this.schema, data);
    if (result.success) {
      this.onValid(result.output);
    } else {
      this.onInvalid(data, result.issues);
    }
  }
}

// ストリーミングバリデーターの使用
const userStreamValidator = new StreamValidator(
  object({ name: string(), age: number() }),
  {
    onValid: (user) => console.log('有効なユーザー:', user),
    onInvalid: (data, issues) => console.log('無効なデータ:', data, issues)
  }
);

// リアルタイムデータ処理
userStreamValidator.process({ name: '田中', age: 30 }); // 有効
userStreamValidator.process({ name: '', age: 'invalid' }); // 無効