Last time, I wrote about different ways of declaring implicits, which is a prelude to this post. Knowledge of different ways of declaring implicits is good for general understanding on the subject, and also for this post.
This post discusses an intriguing case - a gotcha, with implicit resolution of instances related to tagged types. Interestingly, newtype
s are safer in that regard.
Consider the following Scala 2.12 code:
package tagged
trait Show[-A] {
def func(a: A): String
}
object Show {
implicit val showString: Show[String] = from[String](identity)
implicit val showInt: Show[Int] = from(_.toString)
// ... more instances you can imagine!
implicit def showIterables[A, C[_] <: Iterable[A]](implicit S: Show[A]): Show[Iterable[A]] =
from(_.map(S.func).mkString("[", ",", "]"))
def from[A](fn: A => String): Show[A] = fn(_)
}
Let us create some types and their type class instances.
import supertagged.{TaggedType, NewType}
package object tagged {
object FirstName extends NewType[String] {
implicit val showFirstName: Show[FirstName] = Show.from { fn =>
s"""FirstName($fn)"""
}
}
type FirstName = FirstName.Type
object LastName extends TaggedType[String] {
implicit val showLastName: Show[LastName] = Show.from { ln =>
s"""LastName($ln)"""
}
}
type LastName = LastName.Type
/**
* Helper that expects an implicit `TypeClass`
* instance for the specified type parameter
* and logs the output of [[TypeClass.func]].
*/
def log[A](a: A)(implicit T: Show[A]): Unit = {
println(T.func(a))
}
}
The code above uses supertagged
, a nice little library that provides tagged and new types.
With that, let us give it a roll.
package tagged
object Main extends App {
log(42)
log("Hello")
log(FirstName("Martin"))
log(LastName("Odersky"))
}
We expect the following output:
42
Hello
FirstName(Martin)
LastName(Odersky)
Indeed so. Except one thing - LastName
, which prints just Odersky
instead of LastName(Odersky)
While the Show
instance for other types were picked up correctly, the instance for LastName
did not. But it printed something. So, which instance did the compiler pick then? That is what we will investigate in this post.
First, let us first figure out which type class is chosen for LastName
. If you are using IntelliJ, you can easily find out which instance is chosen. You should see that the showString
is the one that is picked even though there is a Show[LastName]
defined. Let us understand why.
The short version of the implicit lookup is as follows. The compiler looks in the following places in the listed order for implicits:
- Local scope
- Imports - Local / Priority1 / Top-level
- Types involved - Parametrized type(s), Type class, etc.
You can read more about this here.
When the compiler looks for an implicit Show[LastName]
in Main
, there is none in local scope. There are no priority or other imports. So, it goes on looking into the types involved. It looks first in the LastName
and its companion, followed by its class hierarchy. One would expect the implicit Show
instance in LastName
would be picked up. But the compiler skips past it because LastName
in this pass looks like a String
. This is also due to the contravarirance involved - Show[-A]
, which makes the compiler look for a specific type - Show[String]
. Not finding an instance in LastName
or its hierachy, the compiler looks into Show
, where it finds an instance - Show[String]
.
Now that we know what the compiler is thinking, the question is: Can we do anything so that the compiler chooses Show[LastName]
instance? Yes, we can.
Here are a few options:
- Make
Show
invariant -Show[A]
. This makes the implicit lookup considerShow[LastName]
consider as a candidate sooner. - Introduce priority import -
import LastName._
, in the local scope. - Declare the instance in
Show
asShow[A <: String]
, which will make the compiler useShow[LastName]
. Because it is more specific. - Declare
LastName
as aNewType
instead ofTaggedType
. BecauseNewType
creates a new type (wrapping the given type).NewType
and given type (String
) are distinct types for the compiler; even though they might exist so only at compile time.NewType
andString
are not interchangeable.
Which option you choose depends on your situation. In my case, #1 wasn’t an option. I went with #3 because I felt that was much cleaner.
Thanks to Bahul Jain, who originally witnessed the problem, and shared it with me.
This issue showed up in a sufficiently large code base at work. The code used above is a simplified version. The original version involved class hierarchies with tagged types, and was tricky.
Thanks to Morgen Peschke for encouraging me to read the official version of the implicit resolution lookup, and apply the lookup algorithm for the case in question.
I hope this post will be useful for Scala developers navigating similar scenarios in their codebases.
-
Priority implicits are the ones that are explicitly referred e.g.
import LastName._
↩︎