Shapeless
ScalaのためのジェネリックプログラミングライブラリShapelessを使用したタイプレベルバリデーション。HListとCoproductによる強力な型安全性を提供
import { Tabs, TabItem } from "@/components/ui/Tabs"; import { Alert, AlertDescription } from "@/components/ui/Alert";
概要
Shapelessは、Scalaのためのジェネリックプログラミングライブラリで、コンパイル時の型レベル計算とバリデーションを可能にします。Miles Sabinによって開発され、HList(heterogeneous list)、Coproduct、タイプクラスデリベーションなどの強力な機能を提供し、従来のリフレクションベースのアプローチよりも安全で高性能なジェネリックプログラミングを実現します。
主な特徴
- タイプレベルプログラミング: コンパイル時に型レベルで計算とバリデーションを実行
- HList (Heterogeneous List): 異なる型の要素を持つリスト構造
- Coproduct: 型安全な和型(Union Type)の実現
- ジェネリックデリベーション: ケースクラスから自動的に型クラスインスタンスを生成
- Lens/Optics: ネストしたデータ構造への型安全なアクセス
- ランタイムオーバーヘッドゼロ: コンパイル時計算によるパフォーマンス最適化
インストール
HListによるバリデーション
HListの基本概念
import shapeless._
// HListの定義
val hlist: String :: Int :: Boolean :: HNil = "hello" :: 42 :: true :: HNil
// 型安全なヘッド・テール操作
val head: String = hlist.head // "hello"
val tail: Int :: Boolean :: HNil = hlist.tail
// 要素へのインデックスアクセス
val first: String = hlist(0)
val second: Int = hlist(1)
val third: Boolean = hlist(2)
HListを使用したフィールドバリデーション
import shapeless._
import shapeless.ops.hlist._
// バリデーション結果型
sealed trait ValidationResult[+A]
case class Valid[A](value: A) extends ValidationResult[A]
case class Invalid(errors: List[String]) extends ValidationResult[Nothing]
// HListバリデーター
trait HListValidator[L <: HList] {
type Out <: HList
def validate(value: L): ValidationResult[Out]
}
object HListValidator {
type Aux[L <: HList, O <: HList] = HListValidator[L] { type Out = O }
// 空のHListのバリデーター
implicit val hNilValidator: HListValidator.Aux[HNil, HNil] =
new HListValidator[HNil] {
type Out = HNil
def validate(value: HNil): ValidationResult[HNil] = Valid(HNil)
}
// ヘッド+テールのバリデーター
implicit def hConsValidator[H, T <: HList, HO, TO <: HList](
implicit
headValidator: Validator[H, HO],
tailValidator: HListValidator.Aux[T, TO]
): HListValidator.Aux[H :: T, HO :: TO] =
new HListValidator[H :: T] {
type Out = HO :: TO
def validate(value: H :: T): ValidationResult[HO :: TO] = {
val headResult = headValidator.validate(value.head)
val tailResult = tailValidator.validate(value.tail)
(headResult, tailResult) match {
case (Valid(h), Valid(t)) => Valid(h :: t)
case (Invalid(he), Invalid(te)) => Invalid(he ++ te)
case (Invalid(he), _) => Invalid(he)
case (_, Invalid(te)) => Invalid(te)
}
}
}
}
// 基本バリデーター
trait Validator[A, B] {
def validate(value: A): ValidationResult[B]
}
object Validator {
def apply[A, B](f: A => ValidationResult[B]): Validator[A, B] =
new Validator[A, B] {
def validate(value: A): ValidationResult[B] = f(value)
}
// 文字列バリデーター
implicit val nonEmptyString: Validator[String, String] =
Validator { str =>
if (str.nonEmpty) Valid(str)
else Invalid(List("文字列は空にできません"))
}
// 数値バリデーター
implicit val positiveInt: Validator[Int, Int] =
Validator { num =>
if (num > 0) Valid(num)
else Invalid(List("数値は正の値である必要があります"))
}
// ブール値バリデーター
implicit val trueBool: Validator[Boolean, Boolean] =
Validator { bool =>
if (bool) Valid(bool)
else Invalid(List("値はtrueである必要があります"))
}
}
Genericによるケースクラスバリデーション
Genericを使用した自動バリデーション
import shapeless._
case class User(name: String, age: Int, active: Boolean)
object GenericValidation {
// Genericを使用してケースクラスをHListに変換
def validateUser(user: User): ValidationResult[User] = {
val gen = Generic[User]
val hlist = gen.to(user)
// HListバリデーターを適用
val validator = implicitly[HListValidator[String :: Int :: Boolean :: HNil]]
validator.validate(hlist) match {
case Valid(validatedHList) => Valid(gen.from(validatedHList))
case Invalid(errors) => Invalid(errors)
}
}
}
// 使用例
val user1 = User("Alice", 25, true)
val result1 = GenericValidation.validateUser(user1)
// => Valid(User("Alice", 25, true))
val user2 = User("", -5, false)
val result2 = GenericValidation.validateUser(user2)
// => Invalid(List("文字列は空にできません", "数値は正の値である必要があります", "値はtrueである必要があります"))
カスタムバリデーターの組み合わせ
// 型クラスベースのバリデーター
trait Validates[A] {
def validate(value: A): ValidationResult[A]
}
object Validates {
def apply[A](implicit v: Validates[A]): Validates[A] = v
implicit val stringValidates: Validates[String] = new Validates[String] {
def validate(value: String): ValidationResult[String] = {
if (value.length >= 2) Valid(value)
else Invalid(List("名前は2文字以上である必要があります"))
}
}
implicit val ageValidates: Validates[Int] = new Validates[Int] {
def validate(value: Int): ValidationResult[Int] = {
if (value >= 18 && value <= 100) Valid(value)
else Invalid(List("年齢は18歳から100歳の間である必要があります"))
}
}
implicit val booleanValidates: Validates[Boolean] = new Validates[Boolean] {
def validate(value: Boolean): ValidationResult[Boolean] = Valid(value)
}
// Genericデリベーション
implicit def genericValidates[A, R](
implicit
gen: Generic.Aux[A, R],
validates: Lazy[Validates[R]]
): Validates[A] = new Validates[A] {
def validate(value: A): ValidationResult[A] = {
validates.value.validate(gen.to(value)) match {
case Valid(r) => Valid(gen.from(r))
case Invalid(errors) => Invalid(errors)
}
}
}
// HListのバリデーション
implicit val hNilValidates: Validates[HNil] = new Validates[HNil] {
def validate(value: HNil): ValidationResult[HNil] = Valid(HNil)
}
implicit def hConsValidates[H, T <: HList](
implicit
hv: Validates[H],
tv: Validates[T]
): Validates[H :: T] = new Validates[H :: T] {
def validate(value: H :: T): ValidationResult[H :: T] = {
(hv.validate(value.head), tv.validate(value.tail)) match {
case (Valid(h), Valid(t)) => Valid(h :: t)
case (Invalid(he), Invalid(te)) => Invalid(he ++ te)
case (Invalid(he), _) => Invalid(he)
case (_, Invalid(te)) => Invalid(te)
}
}
}
}
Coproductによる和型バリデーション
Coproductの基本概念と使用
import shapeless._
// 和型の定義
type StringOrIntOrBool = String :+: Int :+: Boolean :+: CNil
// Coproductの値
val strValue: StringOrIntOrBool = Coproduct[StringOrIntOrBool]("hello")
val intValue: StringOrIntOrBool = Coproduct[StringOrIntOrBool](42)
val boolValue: StringOrIntOrBool = Coproduct[StringOrIntOrBool](true)
// パターンマッチング
def processCoproduct(value: StringOrIntOrBool): String = value.fold(
string => s"文字列: $string",
int => s"数値: $int",
bool => s"ブール値: $bool"
)
Coproductバリデーション
// Coproductバリデーター
trait CoproductValidator[C <: Coproduct] {
type Out <: Coproduct
def validate(value: C): ValidationResult[Out]
}
object CoproductValidator {
type Aux[C <: Coproduct, O <: Coproduct] = CoproductValidator[C] { type Out = O }
// CNilのバリデーター
implicit val cNilValidator: CoproductValidator.Aux[CNil, CNil] =
new CoproductValidator[CNil] {
type Out = CNil
def validate(value: CNil): ValidationResult[CNil] =
sys.error("不可能な状態: CNilは値を持ちません")
}
// :+:のバリデーター
implicit def cConsValidator[H, T <: Coproduct, HO, TO <: Coproduct](
implicit
headValidator: Validator[H, HO],
tailValidator: CoproductValidator.Aux[T, TO]
): CoproductValidator.Aux[H :+: T, HO :+: TO] =
new CoproductValidator[H :+: T] {
type Out = HO :+: TO
def validate(value: H :+: T): ValidationResult[HO :+: TO] = {
value.eliminate(
h => headValidator.validate(h).map(Inl(_)),
t => tailValidator.validate(t).map(Inr(_))
)
}
}
}
// 拡張メソッド
implicit class ValidationResultOps[A](result: ValidationResult[A]) {
def map[B](f: A => B): ValidationResult[B] = result match {
case Valid(value) => Valid(f(value))
case Invalid(errors) => Invalid(errors)
}
}
ラベル付きジェネリック(LabelledGeneric)
フィールド名を保持するバリデーション
import shapeless._
import shapeless.labelled._
case class Person(name: String, age: Int, email: String)
// ラベル付きジェネリック使用
val personGen = LabelledGeneric[Person]
val person = Person("太郎", 25, "[email protected]")
val labelledHList = personGen.to(person)
// フィールド名付きバリデーター
trait FieldValidator[K, V] {
def validate(value: V): ValidationResult[V]
}
object FieldValidator {
implicit val nameValidator: FieldValidator['name, String] =
new FieldValidator['name, String] {
def validate(value: String): ValidationResult[String] = {
if (value.nonEmpty && value.length >= 2) Valid(value)
else Invalid(List("name: 名前は2文字以上である必要があります"))
}
}
implicit val ageValidator: FieldValidator['age, Int] =
new FieldValidator['age, Int] {
def validate(value: Int): ValidationResult[Int] = {
if (value >= 0 && age <= 150) Valid(value)
else Invalid(List("age: 年齢は0歳から150歳の間である必要があります"))
}
}
implicit val emailValidator: FieldValidator['email, String] =
new FieldValidator['email, String] {
def validate(value: String): ValidationResult[String] = {
val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$".r
if (emailRegex.matches(value)) Valid(value)
else Invalid(List("email: 有効なメールアドレスを入力してください"))
}
}
}
// ラベル付きHListバリデーター
trait LabelledHListValidator[L <: HList] {
def validate(value: L): ValidationResult[L]
}
object LabelledHListValidator {
implicit val hNilValidator: LabelledHListValidator[HNil] =
new LabelledHListValidator[HNil] {
def validate(value: HNil): ValidationResult[HNil] = Valid(HNil)
}
implicit def hConsValidator[K <: Symbol, V, T <: HList](
implicit
key: Witness.Aux[K],
fieldValidator: FieldValidator[K, V],
tailValidator: LabelledHListValidator[T]
): LabelledHListValidator[FieldType[K, V] :: T] =
new LabelledHListValidator[FieldType[K, V] :: T] {
def validate(value: FieldType[K, V] :: T): ValidationResult[FieldType[K, V] :: T] = {
val head = value.head
val tail = value.tail
(fieldValidator.validate(head), tailValidator.validate(tail)) match {
case (Valid(h), Valid(t)) => Valid(field[K](h) :: t)
case (Invalid(he), Invalid(te)) => Invalid(he ++ te)
case (Invalid(he), _) => Invalid(he)
case (_, Invalid(te)) => Invalid(te)
}
}
}
}
高度なタイプレベルバリデーション
依存型を使用したバリデーション
import shapeless._
import shapeless.ops.nat._
// 長さの制約がある文字列型
trait SizedString[N <: Nat] {
def value: String
def length: Int
}
object SizedString {
def apply[N <: Nat](str: String)(implicit toInt: ToInt[N]): Option[SizedString[N]] = {
if (str.length == toInt()) Some(new SizedString[N] {
def value: String = str
def length: Int = str.length
}) else None
}
}
// タイプレベル長さバリデーション
trait LengthValidator[A, N <: Nat] {
def validate(value: A): ValidationResult[SizedString[N]]
}
object LengthValidator {
implicit def stringLengthValidator[N <: Nat](
implicit toInt: ToInt[N]
): LengthValidator[String, N] =
new LengthValidator[String, N] {
def validate(value: String): ValidationResult[SizedString[N]] = {
SizedString[N](value) match {
case Some(sized) => Valid(sized)
case None => Invalid(List(s"文字列は正確に${toInt()}文字である必要があります"))
}
}
}
}
// 使用例
type FiveCharString = SizedString[_5]
val validator = implicitly[LengthValidator[String, _5]]
val result1 = validator.validate("hello") // Valid
val result2 = validator.validate("hi") // Invalid
タイプレベルリストの制約
import shapeless._
import shapeless.ops.hlist._
// 最小長制約
trait MinLength[L <: HList, N <: Nat]
object MinLength {
implicit def minLengthConstraint[L <: HList, N <: Nat](
implicit
len: Length.Aux[L, N],
constraint: N >= _2 // 最小2要素
): MinLength[L, N] = new MinLength[L, N] {}
}
// HListの最小長バリデーション
def validateMinLength[L <: HList, N <: Nat](hlist: L)(
implicit minLength: MinLength[L, N]
): ValidationResult[L] = Valid(hlist)
// コンパイル時制約の例
val validList = "a" :: "b" :: "c" :: HNil
val result1 = validateMinLength(validList) // コンパイル成功
// val invalidList = "a" :: HNil
// val result2 = validateMinLength(invalidList) // コンパイルエラー
実践的なバリデーション例
JSONスキーマバリデーション
import shapeless._
import shapeless.labelled._
// JSONスキーマ定義
case class UserSchema(
name: String,
age: Int,
email: String,
address: AddressSchema
)
case class AddressSchema(
street: String,
city: String,
zipCode: String
)
// ネストしたバリデーション
trait SchemaValidator[A] {
def validate(value: A): ValidationResult[A]
}
object SchemaValidator {
implicit val addressValidator: SchemaValidator[AddressSchema] =
new SchemaValidator[AddressSchema] {
def validate(address: AddressSchema): ValidationResult[AddressSchema] = {
val streetResult = if (address.street.nonEmpty) Valid(address.street)
else Invalid(List("street: 住所は必須です"))
val cityResult = if (address.city.nonEmpty) Valid(address.city)
else Invalid(List("city: 市区町村は必須です"))
val zipResult = if (address.zipCode.matches("\\d{3}-\\d{4}")) Valid(address.zipCode)
else Invalid(List("zipCode: 郵便番号はXXX-XXXX形式である必要があります"))
(streetResult, cityResult, zipResult) match {
case (Valid(_), Valid(_), Valid(_)) => Valid(address)
case _ => Invalid(
List(streetResult, cityResult, zipResult).collect {
case Invalid(errors) => errors
}.flatten
)
}
}
}
implicit val userValidator: SchemaValidator[UserSchema] =
new SchemaValidator[UserSchema] {
def validate(user: UserSchema): ValidationResult[UserSchema] = {
val nameResult = if (user.name.length >= 2) Valid(user.name)
else Invalid(List("name: 名前は2文字以上である必要があります"))
val ageResult = if (user.age >= 18) Valid(user.age)
else Invalid(List("age: 年齢は18歳以上である必要があります"))
val emailResult = if (user.email.contains("@")) Valid(user.email)
else Invalid(List("email: 有効なメールアドレスが必要です"))
val addressResult = addressValidator.validate(user.address)
(nameResult, ageResult, emailResult, addressResult) match {
case (Valid(_), Valid(_), Valid(_), Valid(_)) => Valid(user)
case _ => Invalid(
List(nameResult, ageResult, emailResult, addressResult).collect {
case Invalid(errors) => errors
}.flatten
)
}
}
}
}
// バリデーション実行
val user = UserSchema(
name = "太郎",
age = 25,
email = "[email protected]",
address = AddressSchema(
street = "東京都渋谷区道玄坂1-2-3",
city = "渋谷区",
zipCode = "150-0043"
)
)
val result = SchemaValidator[UserSchema].validate(user)
設定ファイルバリデーション
import shapeless._
// アプリケーション設定
case class DatabaseConfig(
host: String,
port: Int,
database: String,
username: String,
password: String
)
case class ServerConfig(
host: String,
port: Int,
ssl: Boolean
)
case class AppConfig(
database: DatabaseConfig,
server: ServerConfig,
logLevel: String
)
// 設定バリデーター
object ConfigValidator {
implicit val databaseConfigValidator: SchemaValidator[DatabaseConfig] =
new SchemaValidator[DatabaseConfig] {
def validate(config: DatabaseConfig): ValidationResult[DatabaseConfig] = {
val hostResult = if (config.host.nonEmpty) Valid(config.host)
else Invalid(List("database.host: データベースホストは必須です"))
val portResult = if (config.port > 0 && config.port <= 65535) Valid(config.port)
else Invalid(List("database.port: ポート番号は1-65535の範囲である必要があります"))
val dbResult = if (config.database.nonEmpty) Valid(config.database)
else Invalid(List("database.database: データベース名は必須です"))
val userResult = if (config.username.nonEmpty) Valid(config.username)
else Invalid(List("database.username: ユーザー名は必須です"))
val passResult = if (config.password.length >= 8) Valid(config.password)
else Invalid(List("database.password: パスワードは8文字以上である必要があります"))
val errors = List(hostResult, portResult, dbResult, userResult, passResult)
.collect { case Invalid(e) => e }.flatten
if (errors.isEmpty) Valid(config) else Invalid(errors)
}
}
implicit val serverConfigValidator: SchemaValidator[ServerConfig] =
new SchemaValidator[ServerConfig] {
def validate(config: ServerConfig): ValidationResult[ServerConfig] = {
val hostResult = if (config.host.nonEmpty) Valid(config.host)
else Invalid(List("server.host: サーバーホストは必須です"))
val portResult = if (config.port > 0 && config.port <= 65535) Valid(config.port)
else Invalid(List("server.port: ポート番号は1-65535の範囲である必要があります"))
val errors = List(hostResult, portResult).collect { case Invalid(e) => e }.flatten
if (errors.isEmpty) Valid(config) else Invalid(errors)
}
}
implicit val appConfigValidator: SchemaValidator[AppConfig] =
new SchemaValidator[AppConfig] {
def validate(config: AppConfig): ValidationResult[AppConfig] = {
val dbResult = databaseConfigValidator.validate(config.database)
val serverResult = serverConfigValidator.validate(config.server)
val logResult = if (List("DEBUG", "INFO", "WARN", "ERROR").contains(config.logLevel))
Valid(config.logLevel)
else Invalid(List("logLevel: ログレベルはDEBUG、INFO、WARN、ERRORのいずれかである必要があります"))
val errors = List(dbResult, serverResult, logResult)
.collect { case Invalid(e) => e }.flatten
if (errors.isEmpty) Valid(config) else Invalid(errors)
}
}
}
パフォーマンス考慮事項
コンパイル時間の最適化
// 遅延評価を使用してコンパイル時間を短縮
import shapeless.Lazy
trait OptimizedValidator[A] {
def validate(value: A): ValidationResult[A]
}
object OptimizedValidator {
// Lazyを使用して循環依存を回避
implicit def genericValidator[A, R](
implicit
gen: Generic.Aux[A, R],
validator: Lazy[OptimizedValidator[R]]
): OptimizedValidator[A] = new OptimizedValidator[A] {
def validate(value: A): ValidationResult[A] = {
validator.value.validate(gen.to(value)) match {
case Valid(_) => Valid(value)
case Invalid(errors) => Invalid(errors)
}
}
}
}
ランタイムパフォーマンス
// 事前計算されたバリデーターのキャッシュ
object CachedValidators {
private val stringValidator: String => ValidationResult[String] = { str =>
if (str.nonEmpty) Valid(str) else Invalid(List("空の文字列は無効です"))
}
private val intValidator: Int => ValidationResult[Int] = { num =>
if (num > 0) Valid(num) else Invalid(List("正の数である必要があります"))
}
// バリデーターファクトリー
def getValidator[A](implicit tag: ClassTag[A]): A => ValidationResult[A] = {
tag.runtimeClass match {
case c if c == classOf[String] =>
stringValidator.asInstanceOf[A => ValidationResult[A]]
case c if c == classOf[Int] =>
intValidator.asInstanceOf[A => ValidationResult[A]]
case _ =>
_ => Invalid(List("サポートされていない型です"))
}
}
}
ベストプラクティス
1. 型安全性の最大化
// 型エイリアスを使用した意図の明確化
type NonEmptyString = String
type PositiveInt = Int
type ValidEmail = String
// Phantom型による制約
trait Validated[A, Constraint]
case class ValidatedValue[A, Constraint](value: A) extends Validated[A, Constraint]
// 制約型の定義
trait NonEmpty
trait Positive
trait EmailFormat
type ValidatedName = ValidatedValue[String, NonEmpty]
type ValidatedAge = ValidatedValue[Int, Positive]
type ValidatedEmail = ValidatedValue[String, EmailFormat]
2. エラーメッセージの階層化
// 構造化されたエラー型
sealed trait ValidationError {
def message: String
def path: String
}
case class FieldError(field: String, constraint: String) extends ValidationError {
def message: String = s"$field: $constraint"
def path: String = field
}
case class NestedError(field: String, errors: List[ValidationError]) extends ValidationError {
def message: String = s"$field has errors: ${errors.map(_.message).mkString(", ")}"
def path: String = field
}
// 階層的バリデーション結果
sealed trait ValidationResult[+A] {
def map[B](f: A => B): ValidationResult[B]
def flatMap[B](f: A => ValidationResult[B]): ValidationResult[B]
}
case class Success[A](value: A) extends ValidationResult[A] {
def map[B](f: A => B): ValidationResult[B] = Success(f(value))
def flatMap[B](f: A => ValidationResult[B]): ValidationResult[B] = f(value)
}
case class Failure(errors: List[ValidationError]) extends ValidationResult[Nothing] {
def map[B](f: Nothing => B): ValidationResult[B] = this
def flatMap[B](f: Nothing => ValidationResult[B]): ValidationResult[B] = this
}
3. テスト戦略
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class ShapelessValidationSpec extends AnyFlatSpec with Matchers {
"HList validation" should "validate all elements" in {
val hlist = "valid" :: 42 :: true :: HNil
val validator = implicitly[HListValidator[String :: Int :: Boolean :: HNil]]
validator.validate(hlist) shouldBe a[Valid[_]]
}
it should "collect all errors" in {
val hlist = "" :: -1 :: false :: HNil
val validator = implicitly[HListValidator[String :: Int :: Boolean :: HNil]]
validator.validate(hlist) match {
case Invalid(errors) =>
errors should have size 3
case _ => fail("Expected validation to fail")
}
}
"Generic validation" should "work with case classes" in {
val user = User("Alice", 25, true)
val result = Validates[User].validate(user)
result shouldBe a[Valid[_]]
}
}
まとめ
Shapelessは、Scalaにおけるタイプレベルプログラミングとバリデーションの強力なツールです。HList、Coproduct、Genericなどの機能を活用することで、コンパイル時に型安全性を保証しながら、柔軟で再利用可能なバリデーションシステムを構築できます。
Shapelessが適している場面
- 型安全性が重要なアプリケーション: 金融システム、医療システムなど
- 複雑なデータ構造の検証: ネストしたJSON、設定ファイル
- ライブラリ開発: 汎用的なバリデーションフレームワーク
- ドメイン駆動設計: 業務ルールの型レベル表現
Shapelessの学習曲線は急峻ですが、マスターすることで、従来のランタイムバリデーションでは実現できない高度な型安全性とパフォーマンスを獲得できます。プロダクション環境での使用には、チーム全体のスキルレベルとプロジェクトの要件を慎重に検討することが重要です。