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との使い分けを理解し、適切な場面で活用することが重要です。