Cats Validated
Scala向けの関数型プログラミングライブラリCatsが提供する、エラーを蓄積しながらバリデーションを行うためのValidated型
概要
Cats Validatedは、Scala向けの関数型プログラミングライブラリCatsが提供するデータ型で、複数のバリデーションエラーを蓄積しながら処理を行うことができます。Either型と異なり、Applicativeファンクターを利用することで、すべてのバリデーションを実行してからエラーをまとめて返すことが可能です。
主な特徴
- エラー蓄積: 複数のバリデーションエラーを収集可能
- Applicativeスタイル: 独立したバリデーションを並列に実行
- 型安全: コンパイル時に型の整合性を保証
- 関数型アプローチ: 純粋関数による合成可能なバリデーション
- 豊富なコンビネータ: map、flatMap、bimap等の操作をサポート
インストール
SBT
libraryDependencies += "org.typelevel" %% "cats-core" % "2.10.0"
Maven
<dependency>
<groupId>org.typelevel</groupId>
<artifactId>cats-core_2.13</artifactId>
<version>2.10.0</version>
</dependency>
基本的な使い方
Validatedの生成
import cats.data.Validated
import cats.data.Validated.{Valid, Invalid}
// 成功の場合
val validInt: Validated[String, Int] = Valid(42)
// 失敗の場合
val invalidInt: Validated[String, Int] = Invalid("不正な値です")
// スマートコンストラクタを使用
val fromOption: Validated[String, Int] =
Validated.fromOption(Some(42), "値が見つかりません")
val fromEither: Validated[String, Int] =
Validated.fromEither(Right(42))
val fromTry: Validated[Throwable, Int] =
Validated.fromTry(scala.util.Try(42))
エラーの蓄積
NonEmptyListを使用したエラー蓄積
import cats.data._
import cats.implicits._
case class User(name: String, age: Int, email: String)
def validateName(name: String): ValidatedNel[String, String] =
if (name.nonEmpty) name.validNel
else "名前は必須です".invalidNel
def validateAge(age: Int): ValidatedNel[String, Int] =
if (age >= 18) age.validNel
else "年齢は18歳以上である必要があります".invalidNel
def validateEmail(email: String): ValidatedNel[String, String] =
if (email.contains("@")) email.validNel
else "有効なメールアドレスを入力してください".invalidNel
// Applicativeスタイルでの合成
def validateUser(name: String, age: Int, email: String): ValidatedNel[String, User] =
(validateName(name), validateAge(age), validateEmail(email))
.mapN(User.apply)
// 使用例
val result1 = validateUser("太郎", 25, "[email protected]")
// => Valid(User("太郎", 25, "[email protected]"))
val result2 = validateUser("", 15, "invalid-email")
// => Invalid(NonEmptyList("名前は必須です", "年齢は18歳以上である必要があります", "有効なメールアドレスを入力してください"))
Applicativeファンクターの活用
mapNを使用した複数の値の結合
import cats.data._
import cats.implicits._
case class Address(
street: String,
city: String,
postalCode: String
)
def validateStreet(street: String): ValidatedNel[String, String] =
if (street.length >= 5) street.validNel
else "住所は5文字以上で入力してください".invalidNel
def validateCity(city: String): ValidatedNel[String, String] =
if (city.nonEmpty) city.validNel
else "市区町村は必須です".invalidNel
def validatePostalCode(code: String): ValidatedNel[String, String] =
if (code.matches("\\d{3}-\\d{4}")) code.validNel
else "郵便番号は XXX-XXXX 形式で入力してください".invalidNel
val validatedAddress = (
validateStreet("東京都渋谷区道玄坂1-2-3"),
validateCity("渋谷区"),
validatePostalCode("150-0043")
).mapN(Address.apply)
traverseを使用したリストのバリデーション
def validatePositive(n: Int): ValidatedNel[String, Int] =
if (n > 0) n.validNel
else s"$n は正の数ではありません".invalidNel
val numbers = List(1, 2, -3, 4, -5)
val validated: ValidatedNel[String, List[Int]] =
numbers.traverse(validatePositive)
// => Invalid(NonEmptyList("-3 は正の数ではありません", "-5 は正の数ではありません"))
他のCats型との統合
EitherとValidatedの相互変換
import cats.data._
import cats.implicits._
// Either to Validated
val either: Either[String, Int] = Right(42)
val validated: Validated[String, Int] = either.toValidated
// Validated to Either
val backToEither: Either[String, Int] = validated.toEither
// ValidatedNel to Either
val validatedNel: ValidatedNel[String, Int] = 42.validNel
val eitherNel: Either[NonEmptyList[String], Int] = validatedNel.toEither
OptionとValidatedの変換
val option: Option[Int] = Some(42)
val fromOption: ValidatedNel[String, Int] =
option.toValidNel("値が見つかりません")
// カスタムバリデーション付き
val validatedOption: ValidatedNel[String, Int] = option match {
case Some(n) if n > 0 => n.validNel
case Some(n) => s"$n は正の数ではありません".invalidNel
case None => "値が存在しません".invalidNel
}
実践的な例
フォームバリデーション
import cats.data._
import cats.implicits._
import java.time.LocalDate
case class RegistrationForm(
username: String,
password: String,
email: String,
birthDate: LocalDate,
agreedToTerms: Boolean
)
object RegistrationValidator {
type ValidationResult[A] = ValidatedNel[String, A]
def validateUsername(username: String): ValidationResult[String] = {
val minLength = 3
val maxLength = 20
val pattern = "^[a-zA-Z0-9_]+$"
if (username.length < minLength)
s"ユーザー名は${minLength}文字以上必要です".invalidNel
else if (username.length > maxLength)
s"ユーザー名は${maxLength}文字以下である必要があります".invalidNel
else if (!username.matches(pattern))
"ユーザー名は英数字とアンダースコアのみ使用できます".invalidNel
else
username.validNel
}
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), "特殊文字を含む必要があります")
)
checks.traverse { case (condition, errorMsg) =>
if (condition) ().validNel else errorMsg.invalidNel
}.map(_ => password)
}
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.validNel
else
"有効なメールアドレスを入力してください".invalidNel
}
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))
"誕生日は未来の日付にできません".invalidNel
else if (age < minAge)
s"${minAge}歳以上である必要があります".invalidNel
else if (age > maxAge)
"有効な誕生日を入力してください".invalidNel
else
date.validNel
}
def validateTerms(agreed: Boolean): ValidationResult[Boolean] =
if (agreed) agreed.validNel
else "利用規約に同意する必要があります".invalidNel
def validateRegistration(
username: String,
password: String,
email: String,
birthDate: LocalDate,
agreedToTerms: Boolean
): ValidationResult[RegistrationForm] = (
validateUsername(username),
validatePassword(password),
validateEmail(email),
validateBirthDate(birthDate),
validateTerms(agreedToTerms)
).mapN(RegistrationForm.apply)
}
EitherとValidatedの比較
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("名前は必須です") // 年齢のエラーは検出されない
Validated(エラー蓄積)
// Validatedはすべてのエラーを収集
def validateWithValidated(name: String, age: Int): ValidatedNel[String, User] = (
validateName(name),
validateAge(age),
"[email protected]".validNel
).mapN(User.apply)
validateWithValidated("", 15)
// => Invalid(NonEmptyList("名前は必須です", "年齢は18歳以上である必要があります"))
高度な使用例
カスタムエラー型の使用
sealed trait ValidationError
case class RequiredField(fieldName: String) extends ValidationError
case class InvalidFormat(fieldName: String, format: String) extends ValidationError
case class OutOfRange(fieldName: String, min: Int, max: Int) extends ValidationError
type ValidationResult[A] = ValidatedNel[ValidationError, A]
def validateAge(age: Int): ValidationResult[Int] =
if (age >= 18 && age <= 100) age.validNel
else OutOfRange("age", 18, 100).invalidNel
def validateEmail(email: String): ValidationResult[String] =
if (email.contains("@")) email.validNel
else InvalidFormat("email", "[email protected]").invalidNel
ValidatedとWriterTの組み合わせ
import cats.data._
import cats.implicits._
type Log = Vector[String]
type LoggedValidation[A] = WriterT[ValidatedNel[String, *], Log, A]
def validateWithLogging(value: Int): LoggedValidation[Int] =
WriterT(
if (value > 0) {
(Vector(s"$value を検証中"), value).validNel
} else {
(Vector(s"$value の検証に失敗"), s"$value は正の数ではありません").invalidNel
}
)
パフォーマンス考慮事項
- Applicativeの並列性: ValidatedのApplicativeインスタンスは概念的に並列ですが、実際の並列実行は行われません
- メモリ使用量: エラーを蓄積するため、大量のエラーがある場合はメモリ使用量に注意
- 型クラスのインポート:
cats.implicits._の代わりに必要なインポートのみを行うことで、コンパイル時間を短縮可能
まとめ
Cats Validatedは、関数型プログラミングのアプローチでバリデーションを行うための強力なツールです。エラーの蓄積機能により、ユーザーにすべてのバリデーションエラーを一度に提示でき、より良いユーザー体験を提供できます。Eitherとの使い分けを理解し、適切な場面で活用することが重要です。