refinedで値が満たすべき条件を型で表現する

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、山崎です。

今回はrefinedというライブラリをご紹介します。

refinedとは

refinedはRefinement Type(篩型)をScalaで実現するためのライブラリで、既存の型に対して型レベルで満たすべき条件を指定することで、取りうる値を制限することができます。もともとはHaskellの同名のライブラリを移植したもののようです

依存ライブラリ

まずRefinedを依存ライブラリに追加します。

libraryDependencies ++= Seq(
  "eu.timepit" %% "refined" % "0.8.4",
)

簡単な例

まず、数値を特定の範囲に制限する例を見ていきましょう。

先に必要なパッケージをimportしておきます。

import eu.timepit.refined._
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.collection._
import eu.timepit.refined.numeric._
import eu.timepit.refined.string._
//正の値
val positive: Int Refined Positive = 10

// コンパイルエラー
// val positive :Int Refined Positive = -1

positiveにはInt Refined Positiveという型が指定されています。Refinedの後ろに指定する型はPredicateと呼ばれ、値が満たすべき性質を表現しています。

今回はPredicateとしてPositiveが指定されているため、右辺に正の値のリテラルを指定した場合のみimplicit conversionによってInt Refined Positiveへの変換が行われ、コンパイルが通るようになります。

また、自分で上限などを指定して範囲を指定できるPredicateも用意されています。

//2.5より小さい
val `lessThan2.5`: Double Refined Less[W.`2.5`.T] = 1.8

//1から3まで
val inTheRange: Int Refined Interval.Closed[W.`1`.T, W.`3`.T] = 2

W.`1`.Tなどとしている部分では1というリテラルの型(Literal Singleton Type)を取り出しています。この型をLessClosedのに渡してやることにより、コンパイル時に条件がチェックされるようになります。

余談ですが、このLiteral Singleton TypeについてはSIP-23で改善案が提案されており、DottyではすでにW.`3`.Tなどとしなくても、

val a: 3 = 3

のようにリテラルを直接使って型を表現することが可能になっているようです。

リテラルでない値についてはコンパイル時に値の性質がわからないため、自動でRefineされた型の値に変換することはできません。 このような場合にはrefineVを使用します。

val eitherPositive: Either[String, Positive] = refineV(userInput)

コレクションや文字列の例

数値だけでなくTraversableや文字列に対しても同様のことを行うことができます。

//必ず一つ以上の要素を含む
  type NonEmptyCollection = Seq[String] Refined NonEmpty

  val strings = List("hoge", "fuga", "tom")
  val nonEmpty: Either[String, NonEmptyCollection] = refineV(strings)

  //正の数値を含む
  type ContainsPositive = Seq[Int] Refined Exists[Positive]

  val numbers = List(1, 3 ,5)
  val containsPositive: Either[String, ContainsPositive] = refineV(numbers) // Right

  //文字列"hoge"から始まる
  val startsWithHoge: String Refined StartsWith[W.`"hoge"`.T] = "hogefuga"

  //パターンにマッチする
  val matched: String Refined MatchesRegex[W.`"[a-z]0[A-Z]+"`.T] = "a0AHFOEHF"

  // コンパイルエラー
  //val matched: String Refined MatchesRegex[W.`"[a-z]0[A-Z]+"`.T] = "abcde"

また、URLや正規表現については以下のようなものが最初から用意されています。

val url: String Refined Url = "http://example.com"

val regex: String Refined Regex = "[a-z]0[A-Z]+"

ここまで幾つかの条件を紹介してきましたが、他にもたくさんのPredicateが用意されており、こちらに紹介されています。

また、独自の型についてのPredicateなどを作成することももちろん可能であり、そちらに関しましてはこちらのドキュメントに方法が紹介されています。

Circeと一緒に使う

Refinedの良いところは対応しているライブラリが多いところです。CirceやPlay Json, ScalaCheckなどはすでに対応するためのモジュールが存在します。例としてCirceと一緒に使うケースを紹介します。

まず、libraryDependenciesを以下のように編集します

libraryDependencies ++= Seq(
  "eu.timepit" %% "refined" % "0.8.4",
  "io.circe" %% "circe-core" % "0.8.0",
  "io.circe" %% "circe-generic" % "0.8.0",
  "io.circe" %% "circe-parser" % "0.8.0",
  "io.circe" %% "circe-refined" % "0.8.0"
)

CirceによってJsonからデコードするcase classを作成します。 case classのそれぞれのフィールドは、型によって満たすべき条件が表現されています。

case class Datum(positive: Int Refined Positive,
                 startWithHoge: String Refined StartsWith[W.`"hoge"`.T])

この値に対してJsonをデコードするコードを書きます。

import eu.timepit.refined._
import eu.timepit.refined.api.Refined
import eu.timepit.refined.numeric.Positive
import eu.timepit.refined.string.StartsWith
import io.circe.parser._
import io.circe.generic.auto._
import io.circe.refined._ //Refinedの型に対応

object RefinedWithCirce extends App {
  val notPositiveJson =
    """
      |{
      |  "positive": -1,
      |  "startWithHoge": "startWithHoge"
      |}
    """.stripMargin //positiveの値が負なのでdecodeに失敗する

  println("decode[Datum](notPositiveJson) = " + decode[Datum](notPositiveJson))

  val validJson =
    """
      |{
      |  "positive": 2,
      |  "startWithHoge" : "hogehoge"
      |}
    """.stripMargin // 正しいJson

  println("decode[Datum](validJson) = " + decode[Datum](validJson))
}

上のコードを実行すると以下のように出力されます。

decode[Datum](notPositiveJson) = Left(DecodingFailure(Predicate failed: (-1 > 0)., List(DownField(positive))))
decode[Datum](validJson) = Right(Datum(2,hogehoge))

型にInt Refined Positiveを指定したフィールドに対してJsonで負の値が入っている場合には、decodeの戻り値としてLeftが返ってきていることがわかります。また、きちんと型で明示された条件を満たす値が入っている時はRightが戻り値として返ってきています。

通常はロジックとして表現する条件を型として表現することで妥当性の検査を含むJsonのデコードがとても簡潔に記述できました。

まとめ

Refinedを使用することで既存の方に満たすべき条件を付加することができることをご紹介しました。また、型として表現しておくことでJsonからデシリアライズする際のバリデーションが簡潔になることもお分かりいただけたと思います。

個人的にもとても興味を惹かれるライブラリで今後も継続的にウォッチしていきたいと思いました。

今回使用したライブラリ

今回の記事のコード

https://github.com/cm-yamasaki-michihiro/refined-example