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).