Search code examples
scalashapelesssingleton-typerefinement-typerefined

Refined and existential types for runtime values


Suppose I want to map between some strings and integer identifiers, and I want my types to make it impossible to get a runtime failure because someone tried to look up an id that was out of range. Here's one straightforward API:

trait Vocab {
  def getId(value: String): Option[Int]
  def getValue(id: Int): Option[String] 
}

This is annoying, though, if users will typically be getting their ids from getId and therefore know they're valid. The following is an improvement in that sense:

trait Vocab[Id] {
  def getId(value: String): Option[Id]
  def getValue(id: Id): String
}

Now we could have something like this:

class TagId private(val value: Int) extends AnyVal

object TagId {
  val tagCount: Int = 100

  def fromInt(id: Int): Option[TagId] =
    if (id >= 0 && id < tagCount) Some(new TagId(id)) else None
}

And then our users can work with Vocab[TagId] and not have to worry about checking whether getValue lookups failed in the typical case, but they can still look up arbitrary integers if they need to. It's still pretty awkward, though, since we have to write a separate type for each kind of thing we want a vocabulary for.

We can also do something like this with refined:

import eu.timepit.refined.api.Refined
import eu.timepit.refined.numeric.Interval.ClosedOpen
import shapeless.Witness

class Vocab(values: Vector[String]) {
  type S <: Int
  type P = ClosedOpen[Witness.`0`.T, S]

  def size: S = values.size.asInstanceOf[S]

  def getId(value: String): Option[Refined[Int, P]] = values.indexOf(value) match {
    case -1 => None
    case i  => Some(Refined.unsafeApply[Int, P](i))
  }

  def getValue(id: Refined[Int, P]): String = values(id.value)
}

Now even though S isn't known at compile time, the compiler is still able to keep track of the fact that the ids it gives us are between zero and S, so that we don't have to worry about the possibility of failure when we go back to values (if we're using the same vocab instance, of course).

What I want is to be able to write this:

val x = 2
val vocab = new Vocab(Vector("foo", "bar", "qux"))

eu.timepit.refined.refineV[vocab.P](x).map(vocab.getValue)

So that users can easily look up arbitrary integers when they really need to. This doesn't compile, though:

scala> eu.timepit.refined.refineV[vocab.P](x).map(vocab.getValue)
<console>:17: error: could not find implicit value for parameter v: eu.timepit.refined.api.Validate[Int,vocab.P]
       eu.timepit.refined.refineV[vocab.P](x).map(vocab.getValue)
                                          ^

I can make it compile by providing a Witness instance for S:

scala> implicit val witVocabS: Witness.Aux[vocab.S] = Witness.mkWitness(vocab.size)
witVocabS: shapeless.Witness.Aux[vocab.S] = shapeless.Witness$$anon$1@485aac3c

scala> eu.timepit.refined.refineV[vocab.P](x).map(vocab.getValue)
res1: scala.util.Either[String,String] = Right(qux)

And of course it fails (at runtime but safely) when the value is out of range:

scala> val y = 3
y: Int = 3

scala> println(eu.timepit.refined.refineV[vocab.P](y).map(vocab.getValue))
Left(Right predicate of (!(3 < 0) && (3 < 3)) failed: Predicate failed: (3 < 3).)

I could also put the witness definition inside my Vocab class and then import vocab._ to make it available when I need this, but what I really want is to be able to provide refineV support without extra imports or definitions.

I've tried various stuff like this:

object Vocab {
  implicit def witVocabS[V <: Vocab](implicit
    witV: Witness.Aux[V]
  ): Witness.Aux[V#S] = Witness.mkWitness(witV.value.size)
}

But this still requires an explicit definition for each vocab instance:

scala> implicit val witVocabS: Witness.Aux[vocab.S] = Vocab.witVocabS
witVocabS: shapeless.Witness.Aux[vocab.S] = shapeless.Witness$$anon$1@1bde5374

scala> eu.timepit.refined.refineV[vocab.P](x).map(vocab.getValue)
res4: scala.util.Either[String,String] = Right(qux)

I know I could implement witVocabS with a macro, but I feel like there should be a nicer way to do this kind of thing, since it seems like a pretty reasonable use case (and I'm not very familiar with refined, so it's entirely possible that I'm missing something obvious).


Solution

  • Turns out that this works as you would like if we make the type parameter S concrete by assigning it the singleton type of values.size using shapeless.Witness:

    import eu.timepit.refined.api.Refined
    import eu.timepit.refined.numeric.Interval.ClosedOpen
    import shapeless.Witness
    
    class Vocab(values: Vector[String]) {
      val sizeStable: Int = values.size
      val sizeWitness = Witness(sizeStable)
    
      type S = sizeWitness.T
      type P = ClosedOpen[Witness.`0`.T, S]
    
      def size: S = sizeWitness.value
    
      def getId(value: String): Option[Refined[Int, P]] = values.indexOf(value) match {
        case -1 => None
        case i => Some(Refined.unsafeApply[Int, P](i))
      }
    
      def getValue(id: Refined[Int, P]): String = values(id.value)
    }
    

    If Scala would allow singleton types of AnyVals, we could remove sizeWitness and define type S = sizeStable.type. This limitation is lifted in the SIP-23 implementation.

    Using refineV now just works even with the path dependant type vocab.P:

    scala> val vocab = new Vocab(Vector("foo", "bar", "baz"))
    vocab: Vocab = Vocab@5fae6bb9
    
    scala> refineV[vocab.P](2)
    res0: Either[String,eu.timepit.refined.api.Refined[Int,vocab.P]] = Right(2)
    
    scala> refineV[vocab.P](4)
    res1: Either[String,eu.timepit.refined.api.Refined[Int,vocab.P]] = Left(Right predicate of (!(4 < 0) && (4 < 3)) failed: Predicate failed: (4 < 3).)
    
    scala> refineV[vocab.P](2).map(vocab.getValue)
    res2: scala.util.Either[String,String] = Right(baz)
    

    This works since the compiler can now find an implicit Witness.Aux[vocab.S] outside the scope of Vocab instances:

    scala> val s = implicitly[shapeless.Witness.Aux[vocab.S]]
    s: shapeless.Witness.Aux[vocab.S] = shapeless.Witness$$anon$1@16cd7aa2
    
    scala> s.value
    res2: s.T = 3
    

    refined now uses this implicit instance to construct a Validate[Int, vocab.P] instance which refineV uses to decide if an Int is valid index for vocab.