SoRの性質が強いBtoBアプリケーションでは、「堅く」作ることを求められる箇所がしばしばあります。
Scalaの型安全性が頼もしく感じられるのは、まさにこのような箇所においてです。

「堅く」作るために、私たちがいま注目しているのが refinednewtype というライブラリです。
この記事では、refindとnewtypeを使ってScalaの型安全性をさらに引き出すテクニックを紹介します。

Value Class / Tagged Type

refined + newtypeの話題に入る前に、これまでにどのようなテクニックが使われてきたかを簡単に振り返りましょう。
ここに、SNSのユーザーアカウントを表現するクラスがあります。

1
2
case class User(id: String, email: String, age: Int)
val user1 = User("@todokr", "tadokoro@example.com", 29)

さて、この素朴な実装にはどんな心配があるでしょう?真っ先に気になるのが、引数をあまりにも簡単に取り違えてしまう恐れがある点ではないでしょうか。

1
2
val invalidUser = User("tadokoro@example.com", "@todokr", 29)
println(invalidUser.email) // @todokr

idもemailも型は同じ String なので、渡す値を取り違えても問題なくコンパイルができてしまいます。

この問題に対しては、Value Class や Tagged Type というテクニックがよく知られています。
下記はValue Classを使う例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
case class UserId(value: String) extends AnyVal
case class Email(value: String) extends AnyVal
case class Age(value: Int) extends AnyVal
case class User(id: UserId, email: Email, age: Age)

val userId = UserId("@todokr")
val email = Email("tadokoro@example.com")
val age = Age(29)
val user = User(email, userId, age)
// コンパイルエラー😉
// type mismatch;
//  found   : Email
//  required: UserId

素のStringやIntをValueClassに変更し、各引数がそれぞれ独自の型を要求するようにしました。渡す値を取り違えるとコンパイルができないようになったので、先のような取り違えはもう起こりません。
これで安心でしょうか?まだ気になる点があります。次の例をご覧ください。

1
2
3
4
val userId = UserId("") // 空の文字列
val email = Email("📧 📧 📧 📧 📧") // Eメールとして不正なフォーマットの文字列
val age = Age(-1) // 負数の年齢
val invalidUser = User(email, userId, age) // でたらめなユーザー😢

Value Classのvalue自体は素朴な String や Int です。Value Classの型で値の「種類」に間違いがないことは保証できても、値の「中身」に間違いがないことは保証できません。
そのため、上の例で見たように、空文字や負の数といった不正な値が入り込む余地があります。

スマートコンストラクタパターン

このような不正な値が入り込むのを防ぐにはどうするのがよいでしょうか?よく使われるテクニックのひとつが、 スマートコンストラクタパターン です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
case class User(id: UserId, email: Email, age: Age)
case class UserId(value: String) extends AnyVal
object UserId {
  def apply(rawUserId: String): Option[UserId] = {
    Some(rawUserId).filter(isValidUserId).map(new UserId(_))
  }

  // `@`から始まって3文字以上16文字以内、@adminや@Adminは許容しない
  private def isValidUserId(s: String): Boolean =
    s.startsWith("@") && s.length >= 3 && s.length <= 16 && !s.matches("(?i)@admin")
}

case class Email(value: String) extends AnyVal
object Email {
  def apply(rawEmail: String): Option[Email] =
    Some(rawEmail).filter(isValidEmail).map(new Email(_))

  // Eメールの簡易的なバリデーションルール
  private def isValidEmail(s: String): Boolean =
    s.matches("""[a-z0-9]+@[a-z0-9]+\.[a-z0-9]{2,}""")
}

case class Age(value: Int) extends AnyVal
object Age {
  def apply(rawAge: Int): Option[Age] =
    Some(rawAge).filter(isValidAge).map(new Age(_))

  // 年齢は0以上、200未満
  private def isValidAge(i: Int): Boolean = i >= 0 && i < 200
}

import cats.data.ValidatedNel
import cats.implicits._

/** 生のパラメータをvalidateしてUserを生成する */
def validateUser(
  rawUserId: String,
  rawEmail: String,
  rawAge: Int
): ValidatedNel[String, User] = (
  UserId(rawUserId).toValidNel(s"Invalid UserId: $rawUserId"),
  Email(rawEmail).toValidNel(s"Invalid Email: $rawEmail"),
  Age(rawAge).toValidNel(s"Invalid Age: $rawAge")
).mapN(User)

UserIdやAgeにスマートコンストラクタを導入しました。コンパニオンオブジェクトに定義されたapplyメソッドは渡された値が妥当であればSomeに包んだValue Classを、そうでなければNoneを返すようになっています。
validateUser メソッドではこれらValue Classのスマートコンストラクタでのインスタンス生成結果と、Catsの .toValidNel を用いてバリデーションを行っています。
このメソッドはID、メールアドレス、年齢の全てが妥当であれば Valid に包まれたUserを、そうでなければ Invalid に包まれた、バリデーションルールを満たさなかった項目についての情報をNonEmptyListとして返します。

1
2
3
4
5
println(validateUser("@todokr", "tadokoro@example.com", 29))
// Valid(User(UserId(@todokr),Email(tadokoro@example.com),Age(29)))

println(validateUser("@ADMIN", "tadokoro.example.com", -1))
// Invalid(NonEmptyList(Invalid UserId: @ADMIN, Invalid Email: tadokoro.example.com, Invalid Age: -1))

ここまでやればもう万全、と言いたいところですが…あと一点だけ気になることがあります!
バリデーションによって不正な値を防ぐことはできましたが、Value Classのvalueの型は相変わらず StringInt のままです。
すなわち、「ユーザーIDは@から始まって3文字以上16文字以内、@adminや@Adminは許容しない」や「年齢は0以上、200以下」といった役立つ情報が型からは失われてしまいました。 そのため、コードベースが大きくなるにつれて、値が検証済みにもかかわらず、バリデーションと同じような意味のないコードをアプリケーションのあちこちに量産してしまうかもしれません。

1
2
3
4
5
6
7
val user = validateUser("@todokr", "tadokoro@example.com", 29)
// ...
// ... 
// ... 定義元より遠い、はるか彼方の銀河系で...
if (!user.id.value.startsWith("@")) { // 無駄なチェック😢
  handleInvalidUserId(user)
}

refined: 値の性質を型で表現する

ならば、「@から始まって3文字以上16文字以内」などの性質を型で表現できないでしょうか?そうすれば、コードベースが大きくなっても開発者は値の性質を正しく理解できるはずです。
そしてあわよくば、その型を直接バリデーションに使うことができないでしょうか?そうすれば、「どのように」ではなく、「どんな性質か」を記述して宣言的にバリデーションルールを定義できるはずです。

それを実現するために、refined があります。これはRefinement Type(篩型)と呼ばれる「型 + 型の性質を示す述語」という型をScalaで簡単に利用するためのライブラリです。

1
2
3
4
5
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.numeric.Positive

val i1: Int Refined Positive = 5

Int Refined PositivePositive が「型の性質を示す述語」です。この述語でIntを “refine” しています。
5 というIntは Positive の性質を満たしているので、このコードは問題なくコンパイルできます。
ではこの性質を満たさない値はどうなるでしょう?

1
2
3
4
5
6
val i2: Int Refined Positive = -5
// [error] Predicate failed: (-5 > 0).
// [error]   val i2: Int Refined Positive = -5
// [error]                                  ^
// [info] Int(-5) <: eu.timepit.refined.api.Refined[Int,eu.timepit.refined.numeric.Positive]?
// [info] false

値が述語の性質を満たさない場合はコンパイルエラーになります。分かりやすいエラーメッセージを出力してくれるのが嬉しいですね。
refinedで定義済みの述語はこちらから確認できます。文字が数値のフォーマットであることを表す Digit や 文字列の空白文字がトリムされていることを表す Trimmed 、開区間/閉区間の情報込みで数値の範囲を表現する Interval.Open / Interval.OpenClosed / Interval.ClosedOpen / Interval.Closed などなど、業務アプリケーションで大活躍しそうな述語が揃っています。

先ほどの UserId を、refinedを使って書き直してみます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import eu.timepit.refined.api._
import eu.timepit.refined.auto._
import eu.timepit.refined.boolean._
import eu.timepit.refined.string._

// `@`から始まって3文字以上16文字以内、@adminや@Adminは許容しない
type UserIdRule = StartsWith["@"] And MinSize[3] And MaxSize[16] And Not[MatchesRegex["(?i)@admin"]]

val userId1: String Refined UserIdRule = "@todokr"

// 以下はコンパイルエラー
val userId2: String Refined UserIdRule = "@admin"
val userId3: String Refined UserIdRule = "todokr"
val userId4: String Refined UserIdRule = "@uryyyyyyyyyyyyyy"

StartsWith["@"] And MinSize[3] And ... を見ると分かるように、述語同士は合成が可能です。
一つ一つの述語は小さくてシンプルですが、組み合わせることで大きくて複雑なルールも簡単に作ることができます。

newtype: オーバーヘッドなしに値をラップする

このように便利なRefinement Typeですが、たまたま同じ型+述語を持つ2種類の値は混同ができてしまいます。

1
2
3
4
5
6
7
8
9
type Id = String Refined NonEmpty
type Password = String Refined NonEmpty

case class LoginInfo(id: Id, password: Password)

val id: Id = "myid123"
val password: Password = "Passw0rd!"

val loginInfo = LoginInfo(id = password, password = id) // コンパイルできてしまう😢

ならばRefinement TypeをValue Classでラップするのはどうでしょう?これなら混同は起きないはずです!

1
2
3
4
5
6
type IdType = String Refined NonEmpty
type PasswordType = String Refined NonEmpty

case class Id(value: IdType) extends AnyVal
case class Password(value: PasswordType) extends AnyVal
case class LoginInfo(id: Id, password: Password)

残念ながら、このコードはコンパイルできません。RefinedはAnyValを継承したValue Classであり、Value Classは他のValue Classをラップできないためです。
extends AnyVal をなくして通常のcase classにするとコンパイルはできますが、Value Classとは異なりBoxing/Unboxingのコストがあるため、この方法は避けたいところです。

ここでの切り札が newtype です。newtypeはランタイムのオーバーヘッドなしに、値をラップするクラスを実現するためのライブラリです。
使い方は簡単で、ラッパークラスを @newtype アノテーションで修飾するだけです。あとはnewtypeのマクロが仕事をしてくれます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type IdType = String Refined NonEmpty
type PasswordType = String Refined NonEmpty

@newtype case class Id(value: IdType)
@newtype case class Password(value: PasswordType)
case class LoginInfo(id: Id, password: Password)

val invalidId = Id("") // 述語が `NonEmpty` なのでコンパイルエラー
val validId = Id("my1d123")
val password = Password("Passw0rd!")

val loginInfo = LoginInfo(id = password, password = validId) // コンパイルエラー😉

これで値の取り違えも防ぐことができました!

Runtime Validation

ここまではコンパイラが誤りを検出してくれる例を見てきました。しかし不正なデータの大半はアプリケーションの外部からやってくるため、コンパイラによる検査では不十分です。 先の例のバリデーションを、refined + newtype でも実装してみます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// `@`から始まって3文字以上16文字以内、@adminや@Adminは許容しない
type UserIdRule = StartsWith["@"] And MinSize[3] And MaxSize[16] And Not[MatchesRegex["(?i)@admin"]]

// Eメールの簡易的なバリデーションルール
type EmailRule = MatchesRegex["""[a-z0-9]+@[a-z0-9]+\.[a-z0-9]{2,}"""]

// 年齢は0以上、200未満
type AgeRule = Interval.OpenClosed[0, 200]

// Refinement Typeの定義
type UserIdString = String Refined UserIdRule
type EmailString = String Refined EmailRule
type AgeInt = Int Refined AgeRule

@newtype case class UserId(value: UserIdString)
object UserId {
  def apply(rawUserId: String): Either[String, UserId] =
    refineV[UserIdRule](rawUserId).map(UserId(_))
}
@newtype case class Email(value: EmailString)
object Email {
  def apply(rawEmail: String): Either[String, Email] =
    refineV[EmailRule](rawEmail).map(Email(_))
}
@newtype case class Age(value: AgeInt)
object Age {
  def apply(rawAge: Int): Either[String, Age] =
     refineV[AgeRule](rawAge).map(Age(_))
}

case class User(userId: UserId, email: Email, age: Age)

import cats.data.ValidatedNel
import cats.implicits._

/** 生のパラメータをvalidateしてUserを生成する */
def validateUser(rawUserId: String, rawEmail: String, rawAge: Int): ValidatedNel[String, User] = (
  UserId(rawUserId).toValidatedNel, // Either[String, UserId] を合成のためにValidatedNelに変換
  Email(rawEmail).toValidatedNel,
  Age(rawAge).toValidatedNel
).mapN(User)

println(validateUser("@todokr", "tadokoro@example.com", 29))
// Valid(User(UserId(@todokr),Email(tadokoro@example.com),Age(29)))

println(validateUser("@ADMIN", "tadokoro.example.com", -1))
// Invalid(NonEmptyList(Invalid UserId: @ADMIN, Invalid Email: tadokoro.example.com, Invalid Age: -1))

先の実装との大きな違いは、スマートコンストラクタの実装を型から自動的に導出している点です。これによってバリデーションルールが宣言的に定義されるようになりました。 もちろん、検査済みの値はRefinement Typeなので、どういった性質の値であるかの情報も失われません。

Work with JSON: Circeと組み合わせる

refinedとnewtypeは各種ライブラリとのインテグレーションも豊富です。まずはJSONライブラリの Circe と組み合わせてみます。 依存ライブラリに circe-refined を追加し、Refinement Type + newtypeの値と他の値をマッピングするDecoder/Encoderを定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
object CoercibleCirceCodecs {
  import io.circe.{Decoder, Encoder, KeyDecoder, KeyEncoder}
  import io.circe.refined._
  import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
  import io.estatico.newtype.Coercible
  import io.estatico.newtype.ops._
  import RefineWithNewtypeModels.User

  implicit def coercibleDecoder[A: Coercible[B, *], B: Decoder]: Decoder[A] =
    Decoder[B].map(_.coerce[A])

  implicit def coercibleEncoder[A: Coercible[B, *], B: Encoder]: Encoder[A] =
    Encoder[B].contramap(_.repr.asInstanceOf[B])

  implicit def coercibleKeyDecoder[A: Coercible[B, *], B: KeyDecoder]: KeyDecoder[A] =
    KeyDecoder[B].map(_.coerce[A])

  implicit def coercibleKeyEncoder[A: Coercible[B, *], B: KeyEncoder]: KeyEncoder[A] =
    KeyEncoder[B].contramap[A](_.repr.asInstanceOf[B])

  implicit val userEncoder: Encoder[User] = deriveEncoder[User]
  implicit val userDecoder: Decoder[User] = deriveDecoder[User]
}

これをimportして、通常通りCirceの decode でJSONをデコードしてみます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
object WorkWithJsonExample extends App {
  import eu.timepit.refined.auto.autoUnwrap // F[T, P]をTにunwrapしてくれる
  import io.circe.parser.decode
  import CoercibleCirceCodecs._

  Iterator
    .continually(Console.in.readLine())
    .takeWhile(_ != ":quit")
    .foreach { rawMessage =>
      decode[User](rawMessage) match {
        case Left(value) => println(value)
        case Right(msg) =>
          println(s"userId: ${msg.userId}")
          println(s"age: ${msg.age}")
          println(s"email: ${msg.email}")
      }
      println("*" * 30)
      println()
    }
}

今回はコンソールに入力した文字列をデコードするようにしました。コンソールに適当なJSONを打ち込んでみます。

1
2
3
4
5
6
7
8
> {"userId": "@todokr", "email": "tadokoro@example.com", "age": 29}
userId: @todokr
age: 29
email: tadokoro@example.com
******************************

> {"userId": "@admin", "email": "", "age": -1}
DecodingFailure(Right predicate of ((("@admin".startsWith("@") && !(6 < 3)...

無事にバリデーションができていることが分かります!

Work with DB: Doobieと組み合わせる

DBから取り出した値をrefined + newtypeの値として扱うにはどうしたらよいでしょうか? doobiedoobie-refined を使用して、findメソッドを実装してみます。

まずは Circe と同じように、値をマッピングするPut/Read、そしてEqを定義します。

1
2
3
4
5
6
7
8
9
object CoercibleDoobieCodec {
  import cats.Eq
  import doobie.{Put, Read}
  import io.estatico.newtype.Coercible

  implicit def coerciblePut[R, N](implicit ev: Coercible[Put[R], Put[N]], R: Put[R]): Put[N] = ev(R)
  implicit def coercibleRead[R, N](implicit ev: Coercible[Read[R], Read[N]], R: Read[R]): Read[N] = ev(R)
  implicit def coercibleEq[R, N](implicit ev: Coercible[Eq[R], Eq[N]], R: Eq[R]): Eq[N] = ev(R)
}

あとはReadやPutの型クラスインスタンスを要求するメソッドから見えるように、上で定義したCodecをimportします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
object WorkWithDbExample extends App {
  import doobie.{ExecutionContexts, Transactor}
  import doobie.implicits._
  import doobie.refined.implicits._
  import cats.effect.{Blocker, ContextShift, IO}

  implicit val cs: ContextShift[IO] = IO.contextShift(ExecutionContexts.synchronous)
  val tx: Transactor[IO] = Transactor.fromDriverManager[IO](
    driver = "org.postgresql.Driver",
    url = "jdbc:postgresql://localhost:9999/test",
    user = "root",
    pass = "root",
    blocker = Blocker.liftExecutionContext(ExecutionContexts.synchronous)
  )

  final class UserRepository(transactor: Transactor[IO]) {
    import CoercibleDoobieCodec._

    def findByEmail(email: String): IO[Option[User]] = {
      sql"""SELECT user_id,  email, age
           |FROM users
           |WHERE email=$email
           |""".stripMargin
        .query[User]
        .option
        .transact(transactor)
    }
  }

  val repository = new UserRepository(tx)
  
  repository.findByEmail("tadokoro@example.com").unsafeRunSync().tap(println)
  // Some(User(@todokr,tadokoro@example.com,29))
}

これで、DBから取得した値もrefined + newtypeの値として扱えるようになりました!

まとめ: refined + newtypeは「堅い」アプリケーションの心強い味方

refined + newtypeを使うことで、記述量や理解のしやすさなどの開発者体験を犠牲にせずに、強固な型安全性の恩恵を受けることができます。

大規模で複雑かつ、バグが許されないアプリケーションにとって、refinedとnewtypeは心強い味方になってくれるはずです。

サンプルコードの全体は、このリポジトリから入手できます。

HRMOS EXチームは、BtoBならではの堅いアプリケーションと、全員がいきいきと働ける柔和なチームを作ることに情熱を傾けられるソフトウェアエンジニアを募集しています。
株式会社ビズリーチ ソフトウェアエンジニア

田所 駿佑
田所 駿佑

HRMOS EXのソフトウェアエンジニア。2015年新卒入社。翔泳社「クローリングハック」共著。特技はアンコウの吊るし切り。一児の父👶