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データ型との相互運用性
インストール
基本的な使い方
インポートと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の豊富な型クラスシステムと組み合わせることで、非常に表現力豊かで安全なバリデーションロジックを構築できます。