Shaplelessで特定の型のフィールドを含むcase classに対する型クラスインスタンスを導出する

shaplessを使って任意の型のフィールドを含むcase classの型クラスインスタンスの導出をしてみました。
2020.05.25

はじめに

Shaplessはジェネリックプログラミングを実現するScalaのライブラリです。 今更ですがAdvanced Scala with Shapeless を読んでいて型クラスインスタンスの導出が面白かったので練習してみました。

使ったライブラリのバージョンなど

"org.typelevel" %% "cats-core" % "2.1.1"
"com.chuusai" %% "shapeless" % "2.3.3"
"org.typelevel" %% "simulacrum" % "1.0.0"

やったこと

まず以下のような型クラスがあります。

@typeclass trait MyValidation[A] {
  @op("validate") def validate(a: A): Either[ValidationError, A]
}

object MyValidation {
  type ValidationError = String

  def instance[A](f: A => Either[ValidationError, A]): MyValidation[A] = (a: A) => f(a)

}

これに対して以下のようなADT ChocolateSnack型のフィールドを持つcase class OyatuBagのインスタンスを導出してみます。ChocolateSnackに対するインスタンスは定義済みです。 (ここではOyatuBagに対するバリデーションの内容はChocolateSnackに関する条件のみとします)

sealed trait ChocolateSnack

object ChocolateSnack {

  final case object Kinoko extends ChocolateSnack

  final case object Takenoko extends ChocolateSnack

  implicit val validationInstance: MyValidation[ChocolateSnack] = MyValidation.instance {
    case Kinoko => "Kinoko is not allowed.".asLeft
    case t@Takenoko => t.asRight
  }
}

//OyatuBagに対するMyValidation[OyatuBag]を導出したい
final case class OyatuBag(choco: ChocolateSnack)

こうなる

出落ちですが、型Bを含むcase class Aに対するMyValidation[A]を導出するコードは以下のようになります。

やっていることは以下の通りです。

  1. Aに対するGeneric gでHListを生成する
  2. HListから#select[B]でBを取り出す
  3. MyValidation[B]#validateにBを渡してバリデーションする
object MyValidation {
  type ValidationError = String

  def instance[A](f: A => Either[ValidationError, A]): MyValidation[A] = (a: A) => f(a)

  //Bを含むcase class Aのインスタンスを導出する
  implicit def genericInstanceWithSpecifiedType[A, R <: HList, B](
                                                                   implicit g: Generic.Aux[A, R],
                                                                   fb: MyValidation[B],
                                                                   select: Selector[R, B]
                                                                 ): MyValidation[A] = instance(
    a => fb.validate(g.to(a).select[B]).map(_ => a)
  )

}

使ってみる

実際に導出されるインスタンスを使ってみるのは以下のようになります。コード中にもありますがChocolateSnackを含まないcase classに対する導出はコンパイルエラーとなります。

object Main extends App {
  import ChocolateSnack.validationInstance
  import MyValidation.ops._

  println(OyatuBag("ann", ChocolateSnack.Kinoko).validate) //Left(Invalid Oyatu)
  println(OyatuBag("kazu", ChocolateSnack.Takenoko).validate) //Right(OyatuBag(kazu,Takenoko))

  // ChocolateSnackを含まないcase classに対するMyValidationは導出できない
  final case class OyatuWithoutChoco(owner:String)
  // Error:(53, 15) Could not find an instance of MyValidation for example.OyatuWithoutChoco
  // MyValidation[OyatuWithoutChoco]

}

まとめ

shaplessを使って任意の型のフィールドを含むcase classの型クラスインスタンスの導出ができました。同一の型のフィールドを持つ複数のcase classに対して挙動を追加したいときにcase classごとに型クラスインスタンスを定義したり、case classにtraitを追加するよりも変更が少なく見通しがいいと思います。