This is a minimal example of what I have encountered today.
The code below is obviously wrong, because both types are erased to just Int, and I am grateful for the warning the compiler produces.
object A {
opaque type ID = Int
}
type Param = Int | A.ID
def take(param: Param) =
param match {
case id: A.ID => println(s"id $id")
case i: Int => println(s"int $i")
}
However, if I fix it and remove the first case id: A.ID, I still get a warning: match might not be exhaustive, even though it is.
Is it possible to make this example compile without a warning, or is suppression the only way? Why is the compiler not able to deduce that the match is in fact exhaustive?
I would expect it to be non-exhaustive. According to opaque type docs, outside the object A it would not be known that A.Id is Int. Am I wrong?
2 Likes
AFAIK, trying to use class checks with opaques is broken by design.
Since class checks run at runtime, the opaque does not exist already, but the pattern match must be checked to be safe at compile time, where the knowledge of opaque erasure should not apply.
2 Likes
I am trying to make a string interpolator that would accept both Int and opaque types for IDs that are Int behind the scenes. No matter what I do, I get some kind of warning.
type Param = Int | User.ID | Game.ID | ...
extension (sc: StringContext) {
def sql(args: Param*): PreparedStatement = {
val statement = c.prepareStatement(sc.parts.mkString("?"))
for ((param, index) <- args.zipWithIndex)
param match // cannot make this work
statement
}
}
I think we should display a warning, either:
- Only when there is actually an issue after erasure (leaky opaque, but only for a warning)
- Emit warnings anytime there is a check for an opaque type, same as
: List[String] (but probably a lot of false positives)
Unless you are in a very performance sensitive context, I highly recommend you to use case classes for this:
object User:
case class ID(value: Int)
...
type Param = Int | User.ID | Game.ID | ...
Or even:
trait Param:
val value: Int
object User:
case class ID(value: Int)
...
Int will not be a Param, but maybe that is also a good thing
Opaque types are very cool, but they have a lot of footguns like this
3 Likes
Can you make all those types <: Int?
That way, the interpolator should just accept Int.
1 Like
The warning in the original code is:
the type test for A.ID cannot be checked at runtime because it refers to an abstract type member or type parameter
The problem is with opaque type you basically do not know anything about the underlying implementation (unless you use some bounds when declaring it) and the compiler is correct in pointing this out. If you want to silence it, you can, e.g.:
def take(param: Param) =
param match {
case i: Int => println(s"int $i")
case id: A.ID@unchecked => println(s"id $id")
}
Note: I do the Int check first, as this is the check which can be done in runtime. If the implementation of A.ID changes, this will still work. The A.ID@unchecked will always succeed.
2 Likes
I realized that making it <: Int is okay and actually simplified a lot of explicit conversions across the codebase, thanks!
@OndrejSpanel This is also a good solution, except one should throw in the @unchecked case. Because the code is unreachable, just the compiler is unwilling to prove it.
1 Like
I have a similar case, in which declaring the bounds seemed like a good idea, but the drawback is that when I do so, it is known everywhere my Id is an Int, allowing me to do things like id + 1, which I do not like much.
It is not that bad, as the result of id + 1 is an Int, not Id, but sometimes it causes confusing error messages when you mistake an Id for an Int.