Scalaz Validation

ScalazライブラリのValidation型を使用した、エラーを蓄積しながら行う関数型バリデーション

import { Tabs, TabItem } from "@/components/ui/Tabs"; import { Alert, AlertDescription } from "@/components/ui/Alert";

概要

Scalaz ValidationはScalazライブラリの一部として提供される、関数型プログラミングスタイルでバリデーションを行うためのデータ型です。Either型と異なり、Applicativeファンクターを利用することで複数のバリデーションエラーを蓄積し、すべてのエラーを一度に報告することが可能です。

主な特徴

  • エラー蓄積: 複数のバリデーションエラーを収集し、全てを一度に報告
  • Applicativeファンクター: 独立したバリデーションの並列実行
  • 型安全: コンパイル時の型チェックによる安全性
  • 関数型アプローチ: 純粋関数による合成可能なバリデーション
  • Scalazエコシステム: 他のScalazデータ型との相互運用性

インストール

```scala libraryDependencies += "org.scalaz" %% "scalaz-core" % "7.3.8" ``` ```xml org.scalaz scalaz-core_2.13 7.3.8 ``` Scala 2.11、2.12、2.13、および3.xをサポートしています。プロジェクトのScalaバージョンに適したものを選択してください。

基本的な使い方

インポートとValidationの生成

import scalaz._
import Scalaz._

// 成功の場合
val success: Validation[String, Int] = 42.success
val successNel: ValidationNel[String, Int] = 42.successNel

// 失敗の場合
val failure: Validation[String, Int] = "エラーメッセージ".failure
val failureNel: ValidationNel[String, Int] = "エラーメッセージ".failureNel

// Eitherからの変換
val fromEither: Validation[String, Int] = Right(42).validation

ValidationNelの使用

import scalaz._
import Scalaz._

case class User(name: String, age: Int, email: String)

// 個別のバリデーション関数
def validateName(name: String): ValidationNel[String, String] = 
  if (name.nonEmpty) name.successNel
  else "名前は必須です".failureNel

def validateAge(age: Int): ValidationNel[String, Int] = 
  if (age >= 18) age.successNel
  else "年齢は18歳以上である必要があります".failureNel

def validateEmail(email: String): ValidationNel[String, String] = 
  if (email.contains("@")) email.successNel
  else "有効なメールアドレスを入力してください".failureNel

// Applicativeスタイルでの合成
def validateUser(name: String, age: Int, email: String): ValidationNel[String, User] = 
  (validateName(name) |@| validateAge(age) |@| validateEmail(email))(User.apply)

// 使用例
val result1 = validateUser("太郎", 25, "[email protected]")
// => Success(User("太郎", 25, "[email protected]"))

val result2 = validateUser("", 15, "invalid-email")
// => Failure(NonEmptyList("名前は必須です", "年齢は18歳以上である必要があります", "有効なメールアドレスを入力してください"))

Applicativeファンクターの活用

ApplicativeBuilderを使用した合成

import scalaz._
import Scalaz._

case class Address(
  street: String,
  city: String,
  postalCode: String
)

def validateStreet(street: String): ValidationNel[String, String] = 
  if (street.length >= 5) street.successNel
  else "住所は5文字以上で入力してください".failureNel

def validateCity(city: String): ValidationNel[String, String] = 
  if (city.nonEmpty) city.successNel
  else "市区町村は必須です".failureNel

def validatePostalCode(code: String): ValidationNel[String, String] = 
  if (code.matches("\\d{3}-\\d{4}")) code.successNel
  else "郵便番号は XXX-XXXX 形式で入力してください".failureNel

// |@|演算子を使用した合成
val validatedAddress = (
  validateStreet("東京都渋谷区道玄坂1-2-3") |@|
  validateCity("渋谷区") |@|
  validatePostalCode("150-0043")
)(Address.apply)

// ^演算子も使用可能
val validatedAddressAlt = 
  validateStreet("東京都渋谷区道玄坂1-2-3") ^
  validateCity("渋谷区") ^
  validatePostalCode("150-0043") apply Address.apply

mapとflatMapの使用

// 成功の場合の値変換
val doubled: ValidationNel[String, Int] = 
  42.successNel.map(_ * 2)

// flatMapを使用した連鎖的なバリデーション
def validateAndProcessAge(age: Int): ValidationNel[String, String] = 
  validateAge(age).flatMap { validAge =>
    if (validAge < 65) s"一般: $validAge 歳".successNel
    else s"シニア: $validAge 歳".successNel
  }

Success/Failureでのパターンマッチング

import scalaz._
import Scalaz._

val validation: ValidationNel[String, Int] = 42.successNel

validation match {
  case Success(value) => 
    println(s"バリデーション成功: $value")
  case Failure(errors) => 
    println(s"バリデーション失敗: ${errors.list.mkString(", ")}")
}

// fold操作
val result = validation.fold(
  errors => s"エラー: ${errors.list.mkString(", ")}",
  value => s"成功: $value"
)

エラーの蓄積

NonEmptyListを使用したエラー蓄積

import scalaz._
import Scalaz._

def validatePositive(n: Int): ValidationNel[String, Int] = 
  if (n > 0) n.successNel
  else s"$n は正の数ではありません".failureNel

// 複数の値を同時にバリデーション
val numbers = List(1, 2, -3, 4, -5)
val validated: ValidationNel[String, List[Int]] = 
  numbers.traverse(validatePositive)
// => Failure(NonEmptyList("-3 は正の数ではありません", "-5 は正の数ではありません"))

// sequenceを使用した変換
val validations: List[ValidationNel[String, Int]] = 
  numbers.map(validatePositive)
val sequenced: ValidationNel[String, List[Int]] = 
  validations.sequence

カスタムエラー型の使用

sealed trait ValidationError
case class RequiredField(fieldName: String) extends ValidationError
case class InvalidFormat(fieldName: String, expected: String) extends ValidationError
case class OutOfRange(fieldName: String, min: Int, max: Int, actual: Int) extends ValidationError

def validateAgeCustom(age: Int): ValidationNel[ValidationError, Int] = 
  if (age >= 18 && age <= 100) age.successNel
  else OutOfRange("age", 18, 100, age).failureNel

def validateEmailCustom(email: String): ValidationNel[ValidationError, String] = 
  if (email.contains("@")) email.successNel
  else InvalidFormat("email", "[email protected]").failureNel

def validateUserCustom(name: String, age: Int, email: String): ValidationNel[ValidationError, User] = 
  (validateName(name).leftMap(RequiredField("name")) |@| 
   validateAgeCustom(age) |@| 
   validateEmailCustom(email))(User.apply)

他のScalazデータ型との統合

EitherとValidationの相互変換

import scalaz._
import Scalaz._

// Either to Validation
val either: Either[String, Int] = Right(42)
val fromEither: Validation[String, Int] = either.validation

// Validation to Either
val validation: Validation[String, Int] = 42.success
val toEither: Either[String, Int] = validation.toEither

// ValidationNel to Either
val validationNel: ValidationNel[String, Int] = 42.successNel
val toEitherNel: Either[NonEmptyList[String], Int] = validationNel.toEither

OptionとValidationの変換

val option: Option[Int] = Some(42)

// OptionをValidationに変換
val fromOption: Validation[String, Int] = 
  option.toSuccess("値が見つかりません")

val fromOptionNel: ValidationNel[String, Int] = 
  option.toSuccessNel("値が見つかりません")

// ValidationをOptionに変換
val validation: Validation[String, Int] = 42.success
val toOption: Option[Int] = validation.toOption

\/(Disjunction)との変換

import scalaz._
import Scalaz._

// Disjunction(scalazのEither的な型)
val disjunction: String \/ Int = 42.right

// DisjunctionをValidationに変換
val fromDisjunction: Validation[String, Int] = disjunction.validation

// ValidationをDisjunctionに変換
val validation: Validation[String, Int] = 42.success
val toDisjunction: String \/ Int = validation.disjunction

実践的な例

フォームバリデーション

import scalaz._
import Scalaz._
import java.time.LocalDate

case class RegistrationForm(
  username: String,
  password: String,
  email: String,
  birthDate: LocalDate,
  agreedToTerms: Boolean
)

object RegistrationValidator {
  type ValidationResult[A] = ValidationNel[String, A]

  def validateUsername(username: String): ValidationResult[String] = {
    val minLength = 3
    val maxLength = 20
    val pattern = "^[a-zA-Z0-9_]+$"
    
    val lengthCheck = if (username.length >= minLength && username.length <= maxLength) 
      username.successNel
    else 
      s"ユーザー名は${minLength}文字以上${maxLength}文字以下である必要があります".failureNel
    
    val formatCheck = if (username.matches(pattern))
      username.successNel
    else
      "ユーザー名は英数字とアンダースコアのみ使用できます".failureNel
    
    // 両方のチェックを通過したユーザー名を返す
    (lengthCheck |@| formatCheck)((_, _) => username)
  }

  def validatePassword(password: String): ValidationResult[String] = {
    val checks = List(
      (password.length >= 8, "パスワードは8文字以上必要です"),
      (password.exists(_.isUpper), "大文字を含む必要があります"),
      (password.exists(_.isLower), "小文字を含む必要があります"),
      (password.exists(_.isDigit), "数字を含む必要があります"),
      (password.exists(!_.isLetterOrDigit), "特殊文字を含む必要があります")
    )
    
    val failures = checks.collect {
      case (false, msg) => msg
    }
    
    if (failures.isEmpty) password.successNel
    else NonEmptyList.fromSeq(failures.head, failures.tail).failureNel
  }

  def validateEmail(email: String): ValidationResult[String] = {
    val emailPattern = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"
    
    if (email.matches(emailPattern)) email.successNel
    else "有効なメールアドレスを入力してください".failureNel
  }

  def validateBirthDate(date: LocalDate): ValidationResult[LocalDate] = {
    val minAge = 18
    val maxAge = 120
    val today = LocalDate.now()
    val age = today.getYear - date.getYear
    
    if (date.isAfter(today))
      "誕生日は未来の日付にできません".failureNel
    else if (age < minAge)
      s"${minAge}歳以上である必要があります".failureNel
    else if (age > maxAge)
      "有効な誕生日を入力してください".failureNel
    else
      date.successNel
  }

  def validateTerms(agreed: Boolean): ValidationResult[Boolean] = 
    if (agreed) agreed.successNel
    else "利用規約に同意する必要があります".failureNel

  def validateRegistration(
    username: String,
    password: String,
    email: String,
    birthDate: LocalDate,
    agreedToTerms: Boolean
  ): ValidationResult[RegistrationForm] = 
    (validateUsername(username) |@| 
     validatePassword(password) |@| 
     validateEmail(email) |@| 
     validateBirthDate(birthDate) |@| 
     validateTerms(agreedToTerms))(RegistrationForm.apply)
}

ビジネスルールのバリデーション

import scalaz._
import Scalaz._

case class Order(
  id: String,
  customerId: String,
  items: List[OrderItem],
  totalAmount: BigDecimal,
  discount: Option[BigDecimal]
)

case class OrderItem(
  productId: String,
  quantity: Int,
  unitPrice: BigDecimal
)

object OrderValidator {
  type ValidationResult[A] = ValidationNel[String, A]

  def validateOrderId(id: String): ValidationResult[String] = 
    if (id.matches("^ORD-\\d{8}$")) id.successNel
    else "注文IDは ORD-XXXXXXXX 形式である必要があります".failureNel

  def validateCustomerId(id: String): ValidationResult[String] = 
    if (id.matches("^CUST-\\d{6}$")) id.successNel
    else "顧客IDは CUST-XXXXXX 形式である必要があります".failureNel

  def validateOrderItem(item: OrderItem): ValidationResult[OrderItem] = 
    (validateProductId(item.productId) |@| 
     validateQuantity(item.quantity) |@| 
     validateUnitPrice(item.unitPrice))(OrderItem.apply)

  def validateProductId(id: String): ValidationResult[String] = 
    if (id.matches("^PRD-\\d{6}$")) id.successNel
    else "商品IDは PRD-XXXXXX 形式である必要があります".failureNel

  def validateQuantity(quantity: Int): ValidationResult[Int] = 
    if (quantity > 0 && quantity <= 100) quantity.successNel
    else "数量は1から100の間である必要があります".failureNel

  def validateUnitPrice(price: BigDecimal): ValidationResult[BigDecimal] = 
    if (price > 0) price.successNel
    else "単価は正の値である必要があります".failureNel

  def validateItems(items: List[OrderItem]): ValidationResult[List[OrderItem]] = {
    if (items.isEmpty) 
      "注文には少なくとも1つの商品が必要です".failureNel
    else 
      items.traverse(validateOrderItem)
  }

  def validateTotalAmount(items: List[OrderItem], totalAmount: BigDecimal): ValidationResult[BigDecimal] = {
    val calculatedTotal = items.map(item => item.quantity * item.unitPrice).sum
    if (totalAmount == calculatedTotal) totalAmount.successNel
    else s"合計金額が一致しません。計算値: $calculatedTotal, 指定値: $totalAmount".failureNel
  }

  def validateDiscount(discount: Option[BigDecimal], totalAmount: BigDecimal): ValidationResult[Option[BigDecimal]] = 
    discount match {
      case Some(amount) if amount < 0 => 
        "割引額は正の値である必要があります".failureNel
      case Some(amount) if amount > totalAmount => 
        "割引額は合計金額を超えることはできません".failureNel
      case other => 
        other.successNel
    }

  def validateOrder(order: Order): ValidationResult[Order] = 
    (validateOrderId(order.id) |@| 
     validateCustomerId(order.customerId) |@| 
     validateItems(order.items) |@| 
     validateTotalAmount(order.items, order.totalAmount) |@| 
     validateDiscount(order.discount, order.totalAmount))(Order.apply)
}

EitherとValidationの比較

Either(fail-fast)

// Eitherは最初のエラーで処理を停止
def validateWithEither(name: String, age: Int): Either[String, User] = 
  for {
    validName <- if (name.nonEmpty) Right(name) else Left("名前は必須です")
    validAge <- if (age >= 18) Right(age) else Left("年齢は18歳以上必要です")
  } yield User(validName, validAge, "")

validateWithEither("", 15)
// => Left("名前は必須です") // 年齢のエラーは検出されない

Validation(エラー蓄積)

// Validationはすべてのエラーを収集
def validateWithValidation(name: String, age: Int): ValidationNel[String, User] = 
  (validateName(name) |@| validateAge(age) |@| "[email protected]".successNel)(User.apply)

validateWithValidation("", 15)
// => Failure(NonEmptyList("名前は必須です", "年齢は18歳以上である必要があります"))

高度なパターン

Validationとモナド変換子

import scalaz._
import Scalaz._

type ValidationT[F[_], E, A] = EitherT[F, NonEmptyList[E], A]

// Future と Validation の組み合わせ
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

def asyncValidateUser(userId: String): Future[ValidationNel[String, User]] = {
  for {
    userOpt <- Future.successful(Some(User("test", 25, "[email protected]"))) // DBアクセスをシミュレート
  } yield userOpt match {
    case Some(user) => user.successNel
    case None => s"ユーザー $userId が見つかりません".failureNel
  }
}

カスタムValidationヘルパー

import scalaz._
import Scalaz._

object ValidationHelpers {
  def nonEmpty[A](value: String, error: A): Validation[A, String] = 
    if (value.nonEmpty) value.success else error.failure

  def nonEmptyNel[A](value: String, error: A): ValidationNel[A, String] = 
    if (value.nonEmpty) value.successNel else error.failureNel

  def inRange[A](value: Int, min: Int, max: Int, error: A): Validation[A, Int] = 
    if (value >= min && value <= max) value.success else error.failure

  def matches[A](value: String, pattern: String, error: A): Validation[A, String] = 
    if (value.matches(pattern)) value.success else error.failure

  def when[A, B](condition: Boolean, value: A, error: B): Validation[B, A] = 
    if (condition) value.success else error.failure
}

// 使用例
import ValidationHelpers._

def validateUserSimple(name: String, age: Int): ValidationNel[String, User] = 
  (nonEmptyNel(name, "名前は必須です") |@| 
   inRange(age, 18, 100, "年齢は18歳から100歳の間である必要があります").toValidationNel |@| 
   "[email protected]".successNel)(User.apply)

テスト

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import scalaz._
import Scalaz._

class ValidationSpec extends AnyFlatSpec with Matchers {
  "validateUser" should "成功ケースで正しいUserを返す" in {
    val result = validateUser("太郎", 25, "[email protected]")
    result shouldBe User("太郎", 25, "[email protected]").successNel
  }

  it should "すべてのエラーを蓄積する" in {
    val result = validateUser("", 15, "invalid-email")
    result match {
      case Failure(errors) =>
        errors.size shouldBe 3
        errors.list should contain("名前は必須です")
        errors.list should contain("年齢は18歳以上である必要があります")
        errors.list should contain("有効なメールアドレスを入力してください")
      case Success(_) => fail("バリデーションが失敗すべきでした")
    }
  }

  it should "部分的な失敗でも該当するエラーのみを返す" in {
    val result = validateUser("太郎", 15, "[email protected]")
    result match {
      case Failure(errors) =>
        errors.size shouldBe 1
        errors.head shouldBe "年齢は18歳以上である必要があります"
      case Success(_) => fail("バリデーションが失敗すべきでした")
    }
  }
}

パフォーマンス考慮事項

  • エラー蓄積のコスト: 大量のエラーを蓄積する場合、メモリ使用量に注意が必要
  • Applicativeの並列性: Validationは概念的に並列ですが、実際の並列実行は行われません
  • NonEmptyListの効率: エラーリストの操作は効率的ですが、非常に大きなリストでは考慮が必要

まとめ

Scalaz Validationは、関数型プログラミングのパラダイムに従って、堅牢でエラーを蓄積可能なバリデーション機能を提供します。Applicativeファンクターを活用することで、複数の独立したバリデーションを並行して実行し、すべてのエラーを収集できるため、ユーザー体験の向上に寄与します。

Scalaz Validationが適している場面

  • フォームバリデーションでユーザーにすべてのエラーを表示したい場合
  • ビジネスルールの検証で複数の条件を同時にチェックしたい場合
  • APIのリクエストバリデーションで詳細なエラー情報が必要な場合
  • 関数型プログラミングのパラダイムを重視する場合
  • Scalazエコシステムの他の機能と統合したい場合

Scalazの豊富な型クラスシステムと組み合わせることで、非常に表現力豊かで安全なバリデーションロジックを構築できます。