Vavr Validation

バリデーションライブラリJava関数型プログラミングモナドアプリカティブファンクターエラー累積

ライブラリ

Vavr Validation

概要

Vavr ValidationはJava向けの関数型バリデーションライブラリで、エラーを累積できるアプリカティブファンクターを提供します。従来の例外ベースやBean Validationとは異なり、関数型プログラミングの原則に基づいた型安全なバリデーションを実現します。複数のバリデーションエラーを収集し、すべてのエラーを一度に報告できるため、フォームバリデーションやAPI入力検証において特に有効です。Vavrの豊富な関数型データ構造と組み合わせることで、表現力豊かで保守性の高いバリデーションロジックを構築できます。

詳細

Vavr Validationは、ScalaのScalazやCatsライブラリにインスパイアされたJava向けの関数型バリデーション実装です。Validation<E, T>型は2つの状態を持ちます:Valid<E, T>(成功)とInvalid<E, T>(失敗)。重要な特徴として、Validationはモナドではなくアプリカティブファンクターとしてモデル化されており、これによりエラーの累積が可能になります。combine()メソッドを使用して最大8つのバリデーションを組み合わせ、すべてのエラーをSeq<E>として収集できます。また、OptionTryEitherといった他のVavr型との相互変換もサポートしており、関数型プログラミングのエコシステム内でシームレスに動作します。

主な特徴

  • エラー累積: 複数のバリデーションエラーを収集し一度に報告
  • 型安全: コンパイル時に型チェックされるバリデーション
  • アプリカティブファンクター: 独立したバリデーションの並列実行と結果の組み合わせ
  • 関数型統合: Option、Try、Either等のVavr型との相互運用
  • 宣言的記述: 関数合成による読みやすいバリデーションロジック
  • 不変性: すべてのバリデーション結果は不変オブジェクト

メリット・デメリット

メリット

  • すべてのバリデーションエラーを一度に収集できる
  • 型安全でコンパイル時にエラーを検出
  • 関数型プログラミングパラダイムとの高い親和性
  • チェーン可能で合成可能なバリデーション
  • null安全で例外を投げない設計
  • テストしやすい純粋関数として実装可能

デメリット

  • 関数型プログラミングの知識が必要
  • 従来のJavaスタイルと異なるため学習曲線が急
  • Bean Validationのようなアノテーションベースではない
  • Spring等のフレームワークとの統合は手動実装が必要
  • パフォーマンスは命令型実装よりわずかに劣る場合がある
  • Vavrライブラリ全体への依存が発生

参考ページ

書き方の例

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

<!-- Maven dependency -->
<dependency>
    <groupId>io.vavr</groupId>
    <artifactId>vavr</artifactId>
    <version>0.10.4</version>
</dependency>
// Gradle dependency
dependencies {
    implementation 'io.vavr:vavr:0.10.4'
}

基本的なバリデーション

import io.vavr.control.Validation;
import io.vavr.collection.List;
import io.vavr.collection.Seq;

public class BasicValidationExample {
    
    // 単純なバリデーション関数
    public static Validation<String, Integer> validateAge(int age) {
        return age >= 0 && age <= 120 
            ? Validation.valid(age)
            : Validation.invalid("年齢は0〜120の範囲で入力してください");
    }
    
    public static Validation<String, String> validateName(String name) {
        return name != null && !name.trim().isEmpty()
            ? Validation.valid(name)
            : Validation.invalid("名前は必須です");
    }
    
    public static Validation<String, String> validateEmail(String email) {
        String emailRegex = "^[A-Za-z0-9+_.-]+@(.+)$";
        return email != null && email.matches(emailRegex)
            ? Validation.valid(email)
            : Validation.invalid("有効なメールアドレスを入力してください");
    }
    
    public static void main(String[] args) {
        // 成功の場合
        Validation<String, Integer> validAge = validateAge(25);
        System.out.println(validAge.isValid()); // true
        System.out.println(validAge.get()); // 25
        
        // 失敗の場合
        Validation<String, Integer> invalidAge = validateAge(150);
        System.out.println(invalidAge.isInvalid()); // true
        System.out.println(invalidAge.getError()); // 年齢は0〜120の範囲で入力してください
        
        // fold を使った処理
        String result = validateEmail("[email protected]").fold(
            error -> "エラー: " + error,
            email -> "メール: " + email
        );
        System.out.println(result); // メール: [email protected]
    }
}

エラー累積とバリデーションの組み合わせ

import io.vavr.control.Validation;
import io.vavr.collection.List;
import io.vavr.collection.Seq;

public class Person {
    private final String name;
    private final int age;
    private final String email;
    
    public Person(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }
    
    // ゲッター
    public String getName() { return name; }
    public int getAge() { return age; }
    public String getEmail() { return email; }
    
    @Override
    public String toString() {
        return String.format("Person(name=%s, age=%d, email=%s)", name, age, email);
    }
}

public class ValidationCombineExample {
    
    // エラーはSeqで累積される
    public static Validation<Seq<String>, Person> validatePerson(
            String name, int age, String email) {
        
        return Validation
            .combine(
                validateName(name),
                validateAge(age),
                validateEmail(email)
            )
            .ap(Person::new);
    }
    
    // 個別のバリデーション関数(エラーをSeqで返す)
    private static Validation<Seq<String>, String> validateName(String name) {
        return name == null || name.trim().isEmpty()
            ? Validation.invalid(List.of("名前は必須です"))
            : name.length() < 2
                ? Validation.invalid(List.of("名前は2文字以上必要です"))
                : Validation.valid(name);
    }
    
    private static Validation<Seq<String>, Integer> validateAge(int age) {
        List<String> errors = List.empty();
        
        if (age < 0) {
            errors = errors.append("年齢は0以上である必要があります");
        }
        if (age > 120) {
            errors = errors.append("年齢は120以下である必要があります");
        }
        
        return errors.isEmpty()
            ? Validation.valid(age)
            : Validation.invalid(errors);
    }
    
    private static Validation<Seq<String>, String> validateEmail(String email) {
        if (email == null) {
            return Validation.invalid(List.of("メールアドレスは必須です"));
        }
        
        String emailRegex = "^[A-Za-z0-9+_.-]+@(.+)$";
        return email.matches(emailRegex)
            ? Validation.valid(email)
            : Validation.invalid(List.of("有効なメールアドレス形式ではありません"));
    }
    
    public static void main(String[] args) {
        // すべてのバリデーションが成功する場合
        Validation<Seq<String>, Person> validPerson = 
            validatePerson("田中太郎", 30, "[email protected]");
        
        if (validPerson.isValid()) {
            System.out.println("バリデーション成功: " + validPerson.get());
        }
        
        // 複数のエラーが発生する場合
        Validation<Seq<String>, Person> invalidPerson = 
            validatePerson("", -5, "invalid-email");
        
        if (invalidPerson.isInvalid()) {
            System.out.println("バリデーションエラー:");
            invalidPerson.getError().forEach(error -> 
                System.out.println("  - " + error)
            );
        }
        
        // fold でエラーと成功の両方を処理
        String result = validatePerson("山田", 25, "[email protected]").fold(
            errors -> "エラー: " + errors.mkString(", "),
            person -> "成功: " + person.toString()
        );
        System.out.println(result);
    }
}

高度なバリデーション技術

import io.vavr.control.Validation;
import io.vavr.control.Option;
import io.vavr.control.Try;
import io.vavr.control.Either;
import io.vavr.collection.List;
import io.vavr.collection.Seq;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public class AdvancedValidationExample {
    
    // カスタムエラー型
    sealed interface ValidationError {
        record FieldError(String field, String message) implements ValidationError {}
        record BusinessError(String code, String message) implements ValidationError {}
    }
    
    // 複雑なドメインオブジェクト
    public static class User {
        private final String id;
        private final String username;
        private final String email;
        private final LocalDate birthDate;
        private final Option<String> phoneNumber;
        
        public User(String id, String username, String email, 
                   LocalDate birthDate, Option<String> phoneNumber) {
            this.id = id;
            this.username = username;
            this.email = email;
            this.birthDate = birthDate;
            this.phoneNumber = phoneNumber;
        }
        
        // ゲッター省略
    }
    
    // ビジネスルールのバリデーション
    public static Validation<Seq<ValidationError>, User> validateUser(
            String id, String username, String email, 
            String birthDateStr, String phoneNumber) {
        
        return Validation
            .combine(
                validateId(id),
                validateUsername(username),
                validateEmail(email),
                validateBirthDate(birthDateStr),
                validatePhoneNumber(phoneNumber)
            )
            .ap(User::new)
            .flatMap(user -> validateBusinessRules(user));
    }
    
    private static Validation<Seq<ValidationError>, String> validateId(String id) {
        if (id == null || id.isEmpty()) {
            return Validation.invalid(List.of(
                new ValidationError.FieldError("id", "IDは必須です")
            ));
        }
        if (!id.matches("^[A-Z0-9]{8}$")) {
            return Validation.invalid(List.of(
                new ValidationError.FieldError("id", "IDは8文字の英数字(大文字)である必要があります")
            ));
        }
        return Validation.valid(id);
    }
    
    private static Validation<Seq<ValidationError>, String> validateUsername(String username) {
        List<ValidationError> errors = List.empty();
        
        if (username == null || username.isEmpty()) {
            errors = errors.append(new ValidationError.FieldError("username", "ユーザー名は必須です"));
        } else {
            if (username.length() < 3) {
                errors = errors.append(new ValidationError.FieldError("username", "ユーザー名は3文字以上必要です"));
            }
            if (username.length() > 20) {
                errors = errors.append(new ValidationError.FieldError("username", "ユーザー名は20文字以下である必要があります"));
            }
            if (!username.matches("^[a-zA-Z0-9_]+$")) {
                errors = errors.append(new ValidationError.FieldError("username", "ユーザー名は英数字とアンダースコアのみ使用できます"));
            }
        }
        
        return errors.isEmpty() ? Validation.valid(username) : Validation.invalid(errors);
    }
    
    private static Validation<Seq<ValidationError>, String> validateEmail(String email) {
        return Option.of(email)
            .filter(e -> e.matches("^[A-Za-z0-9+_.-]+@(.+)$"))
            .toValidation(List.of(new ValidationError.FieldError("email", "有効なメールアドレスを入力してください")));
    }
    
    private static Validation<Seq<ValidationError>, LocalDate> validateBirthDate(String dateStr) {
        return Try.of(() -> LocalDate.parse(dateStr, DateTimeFormatter.ISO_DATE))
            .toValidation()
            .mapError(throwable -> List.of(
                new ValidationError.FieldError("birthDate", "日付形式が無効です: " + throwable.getMessage())
            ))
            .flatMap(date -> {
                LocalDate now = LocalDate.now();
                LocalDate minDate = now.minusYears(120);
                
                if (date.isAfter(now)) {
                    return Validation.invalid(List.of(
                        new ValidationError.FieldError("birthDate", "生年月日は未来の日付にできません")
                    ));
                }
                if (date.isBefore(minDate)) {
                    return Validation.invalid(List.of(
                        new ValidationError.FieldError("birthDate", "生年月日が古すぎます")
                    ));
                }
                return Validation.valid(date);
            });
    }
    
    private static Validation<Seq<ValidationError>, Option<String>> validatePhoneNumber(String phone) {
        if (phone == null || phone.isEmpty()) {
            return Validation.valid(Option.none());
        }
        
        String phoneRegex = "^\\d{2,4}-\\d{2,4}-\\d{4}$";
        return phone.matches(phoneRegex)
            ? Validation.valid(Option.some(phone))
            : Validation.invalid(List.of(
                new ValidationError.FieldError("phoneNumber", "電話番号の形式が無効です(例:03-1234-5678)")
            ));
    }
    
    // ビジネスルールのバリデーション(全フィールドを使った検証)
    private static Validation<Seq<ValidationError>, User> validateBusinessRules(User user) {
        List<ValidationError> errors = List.empty();
        
        // 未成年の場合は電話番号必須
        LocalDate eighteenYearsAgo = LocalDate.now().minusYears(18);
        if (user.birthDate.isAfter(eighteenYearsAgo) && user.phoneNumber.isEmpty()) {
            errors = errors.append(new ValidationError.BusinessError(
                "MINOR_PHONE_REQUIRED", 
                "未成年の場合は電話番号の登録が必須です"
            ));
        }
        
        // その他のビジネスルール...
        
        return errors.isEmpty() ? Validation.valid(user) : Validation.invalid(errors);
    }
    
    // Either や Try との相互変換
    public static void conversionExample() {
        // Either から Validation へ
        Either<String, Integer> either = Either.right(42);
        Validation<String, Integer> fromEither = Validation.fromEither(either);
        
        // Try から Validation へ
        Try<String> tryValue = Try.of(() -> "success");
        Validation<Throwable, String> fromTry = Validation.fromTry(tryValue);
        
        // Validation から Either へ
        Validation<String, Integer> validation = Validation.valid(100);
        Either<String, Integer> toEither = validation.toEither();
        
        // Option から Validation へ
        Option<String> option = Option.of("value");
        Validation<String, String> fromOption = option.toValidation("値が存在しません");
    }
    
    // シーケンスとトラバース
    public static void sequenceExample() {
        // 複数のバリデーションをシーケンス化
        List<Validation<Seq<String>, Integer>> validations = List.of(
            Validation.valid(1),
            Validation.valid(2),
            Validation.valid(3)
        );
        
        Validation<Seq<String>, Seq<Integer>> sequenced = 
            Validation.sequence(validations);
        
        System.out.println(sequenced); // Valid(List(1, 2, 3))
        
        // エラーがある場合
        List<Validation<Seq<String>, Integer>> withErrors = List.of(
            Validation.valid(1),
            Validation.invalid(List.of("エラー1")),
            Validation.invalid(List.of("エラー2"))
        );
        
        Validation<Seq<String>, Seq<Integer>> sequencedWithErrors = 
            Validation.sequence(withErrors);
        
        System.out.println(sequencedWithErrors); // Invalid(List(エラー1, エラー2))
        
        // トラバース(マップしてからシーケンス)
        List<String> values = List.of("1", "2", "3");
        Validation<Seq<String>, Seq<Integer>> traversed = 
            Validation.traverse(values, str -> 
                Try.of(() -> Integer.parseInt(str))
                    .toValidation()
                    .mapError(t -> List.of("パースエラー: " + str))
            );
    }
}

実践的な使用例:APIリクエストバリデーション

import io.vavr.control.Validation;
import io.vavr.collection.List;
import io.vavr.collection.Seq;
import io.vavr.collection.HashMap;
import io.vavr.collection.Map;
import io.vavr.control.Option;

public class ApiValidationExample {
    
    // APIリクエストDTO
    public static class CreateUserRequest {
        private final Map<String, Object> data;
        
        public CreateUserRequest(Map<String, Object> data) {
            this.data = data;
        }
        
        public Option<String> getString(String key) {
            return data.get(key).flatMap(v -> 
                v instanceof String ? Option.of((String) v) : Option.none()
            );
        }
        
        public Option<Integer> getInt(String key) {
            return data.get(key).flatMap(v -> 
                v instanceof Integer ? Option.of((Integer) v) : Option.none()
            );
        }
    }
    
    // APIレスポンス
    public sealed interface ApiResponse<T> {
        record Success<T>(T data) implements ApiResponse<T> {}
        record Error<T>(int code, Seq<String> messages) implements ApiResponse<T> {}
    }
    
    // バリデーション付きAPIハンドラー
    public static class UserApiHandler {
        
        public ApiResponse<User> createUser(CreateUserRequest request) {
            return validateCreateUserRequest(request).fold(
                errors -> new ApiResponse.Error<>(400, errors),
                user -> new ApiResponse.Success<>(user)
            );
        }
        
        private Validation<Seq<String>, User> validateCreateUserRequest(CreateUserRequest request) {
            return Validation
                .combine(
                    request.getString("username")
                        .toValidation(List.of("usernameは必須です"))
                        .flatMap(this::validateUsername),
                    request.getString("email")
                        .toValidation(List.of("emailは必須です"))
                        .flatMap(this::validateEmail),
                    request.getInt("age")
                        .toValidation(List.of("ageは必須です"))
                        .flatMap(this::validateAge)
                )
                .ap((username, email, age) -> 
                    new User(generateId(), username, email, age)
                );
        }
        
        private Validation<Seq<String>, String> validateUsername(String username) {
            if (username.length() < 3) {
                return Validation.invalid(List.of("ユーザー名は3文字以上必要です"));
            }
            if (username.length() > 20) {
                return Validation.invalid(List.of("ユーザー名は20文字以下にしてください"));
            }
            if (!username.matches("^[a-zA-Z0-9]+$")) {
                return Validation.invalid(List.of("ユーザー名は英数字のみ使用できます"));
            }
            return Validation.valid(username);
        }
        
        private Validation<Seq<String>, String> validateEmail(String email) {
            return email.matches("^[A-Za-z0-9+_.-]+@(.+)$")
                ? Validation.valid(email)
                : Validation.invalid(List.of("有効なメールアドレスを入力してください"));
        }
        
        private Validation<Seq<String>, Integer> validateAge(Integer age) {
            if (age < 0) {
                return Validation.invalid(List.of("年齢は0以上である必要があります"));
            }
            if (age > 120) {
                return Validation.invalid(List.of("年齢は120以下である必要があります"));
            }
            return Validation.valid(age);
        }
        
        private String generateId() {
            return java.util.UUID.randomUUID().toString();
        }
    }
    
    // 使用例
    public static void main(String[] args) {
        UserApiHandler handler = new UserApiHandler();
        
        // 正常なリクエスト
        Map<String, Object> validData = HashMap.of(
            "username", "testuser",
            "email", "[email protected]",
            "age", 25
        );
        
        ApiResponse<User> successResponse = handler.createUser(
            new CreateUserRequest(validData)
        );
        
        switch (successResponse) {
            case ApiResponse.Success<User> success -> 
                System.out.println("ユーザー作成成功: " + success.data());
            case ApiResponse.Error<User> error -> 
                System.out.println("エラー: " + error.messages().mkString(", "));
        }
        
        // エラーのあるリクエスト
        Map<String, Object> invalidData = HashMap.of(
            "username", "ab",  // 短すぎる
            "email", "invalid", // 無効な形式
            "age", -5 // 負の値
        );
        
        ApiResponse<User> errorResponse = handler.createUser(
            new CreateUserRequest(invalidData)
        );
        
        switch (errorResponse) {
            case ApiResponse.Success<User> success -> 
                System.out.println("成功: " + success.data());
            case ApiResponse.Error<User> error -> {
                System.out.println("バリデーションエラー:");
                error.messages().forEach(msg -> 
                    System.out.println("  - " + msg)
                );
            }
        }
    }
    
    static class User {
        private final String id;
        private final String username;
        private final String email;
        private final int age;
        
        public User(String id, String username, String email, int age) {
            this.id = id;
            this.username = username;
            this.email = email;
            this.age = age;
        }
        
        @Override
        public String toString() {
            return String.format("User(id=%s, username=%s, email=%s, age=%d)", 
                id, username, email, age);
        }
    }
}

テスト駆動開発での活用

import io.vavr.control.Validation;
import io.vavr.collection.List;
import io.vavr.collection.Seq;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class ValidationTest {
    
    @Test
    void testValidInput() {
        // Given
        String validEmail = "[email protected]";
        
        // When
        Validation<Seq<String>, String> result = validateEmail(validEmail);
        
        // Then
        assertTrue(result.isValid());
        assertEquals(validEmail, result.get());
    }
    
    @Test
    void testInvalidInput() {
        // Given
        String invalidEmail = "not-an-email";
        
        // When
        Validation<Seq<String>, String> result = validateEmail(invalidEmail);
        
        // Then
        assertTrue(result.isInvalid());
        assertTrue(result.getError().contains("有効なメールアドレスを入力してください"));
    }
    
    @Test
    void testMultipleErrors() {
        // Given
        String name = "";
        int age = -5;
        String email = "invalid";
        
        // When
        Validation<Seq<String>, Person> result = validatePerson(name, age, email);
        
        // Then
        assertTrue(result.isInvalid());
        Seq<String> errors = result.getError();
        assertEquals(3, errors.size());
        assertTrue(errors.contains("名前は必須です"));
        assertTrue(errors.contains("年齢は0以上である必要があります"));
        assertTrue(errors.contains("有効なメールアドレス形式ではありません"));
    }
    
    @Test
    void testValidationComposition() {
        // Given
        Validation<String, Integer> valid1 = Validation.valid(10);
        Validation<String, Integer> valid2 = Validation.valid(20);
        
        // When
        Validation<List<String>, Integer> combined = valid1
            .combine(valid2)
            .ap((a, b) -> a + b);
        
        // Then
        assertTrue(combined.isValid());
        assertEquals(30, combined.get());
    }
    
    private Validation<Seq<String>, String> validateEmail(String email) {
        return email != null && email.matches("^[A-Za-z0-9+_.-]+@(.+)$")
            ? Validation.valid(email)
            : Validation.invalid(List.of("有効なメールアドレスを入力してください"));
    }
    
    private Validation<Seq<String>, Person> validatePerson(String name, int age, String email) {
        return Validation
            .combine(
                name == null || name.isEmpty() 
                    ? Validation.invalid(List.of("名前は必須です"))
                    : Validation.valid(name),
                age < 0 
                    ? Validation.invalid(List.of("年齢は0以上である必要があります"))
                    : Validation.valid(age),
                validateEmail(email)
            )
            .ap(Person::new);
    }
    
    record Person(String name, int age, String email) {}
}