Tagged Types & Implicit Resolution

HKT HKT
Views
Article hero image

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, newtypes 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 consider Show[LastName] consider as a candidate sooner.
  • Introduce priority import - import LastName._ , in the local scope.
  • Declare the instance in Show as Show[A <: String] , which will make the compiler use Show[LastName] . Because it is more specific.
  • Declare LastName as a NewType instead of TaggedType . Because NewType 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 and String 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.


  1. Priority implicits are the ones that are explicitly referred e.g. import LastName._ ↩︎

scala sbt tagged-types implicit