scalaimplicit

Implicit resolution failure when multiple instances present, despite having different types


My goal is to implement a Schema[T] (from scala-jsonschema) and Writes[T] (from play-json) for a handful of T classes whose companions I can't modify. My sub-goal is to define them close together to help them stay in sync in case of refactoring.

To that end, I've created a little helper trait to pair them together, and some implicits to pull the schema and writes out implicitly

trait SchemaHelper[T] {
  def schema: Schema[T]
  def writes: Writes[T]
}

implicit def writesFromHelper[T](implicit c: SchemaHelper[T]): Writes[T] = c.writes
implicit def schemaFromHelper[T](implicit c: SchemaHelper[T]): Schema[T] = c.schema

The problem I'm seeing is that if I define more than one SchemaHelper in the current implicit search scope (even if they are different types; this isn't an ambiguous implicits problem), none of them work.

For example:

object MySchemas {
  implicit val stringHelper: SchemaHelper[String] = new SchemaHelper[String] {
    def schema = Schema.string
    def writes = Writes.of[String]
  }

  implicit val intHelper: SchemaHelper[Int] = new SchemaHelper[Int] {
    def schema = Schema.integer
    def writes = Writes.of[Int]
  }

  implicit val fooHelper: SchemaHelper[Foo] = new SchemaHelper[Foo] {
    // this example uses macro-generated values, but many of my
    // actual classes will not, or will build on the macro-generated result
    def schema = json.Json.schema[Foo]
    def writes = play.api.libs.json.Json.writes[Foo]
  }

  // ...and so on, but with my actual classes
}

object Main extends App {
  implicit def writesFromHelper[T](implicit c: SchemaHelper[T]): Writes[T] = c.writes
  implicit def schemaFromHelper[T](implicit c: SchemaHelper[T]): Schema[T] = c.schema

  import MySchemas._

  val exampleSchema = implicitly[Schema[Foo]] // does not work
  val exampleSchema2 = schemaFromHelper[Foo// works
}

In this example, implicitly[Schema[Foo]] does not resolve (even though IntelliJ thinks it does, and even points to schemaFromHelper in its implicit popup thing).

If I comment out the stringHelper and intHelper, so that fooHelper is the only SchemaHelper implicit defined in MySchemas, implicitly[Schema[Foo]] works again. This runs counter to my understanding of... every single type-class. I should be able to have an implicit SchemaHelper[A] and SchemaHelper[B] in scope without things falling apart.

Example on Scastie

In my attempts to debug, I found the -Xlog-implicits flag, which gives me... unhelpful output:

[error] <redacted>\schemas.scala:17:29: implicit error;
[error] !I e: json.Schema[example.Foo]
[error] schemaFromHelper invalid because
[error] !I evidence$2: example.SchemaHelper[T]
[error]                 val exampleSchema = implicitly[Schema[Foo]]
[error]                                               ^
[error] one error found
[error] (Compile / compileIncremental) Compilation failed

At this point I'm stumped. This seems like a compiler bug to me but I'm not sure.


Solution

  • The thing seems to be in covariance of Schema[T] while Writes[T] and SchemaHelper[T] are invariant type classes.

    Indeed,

    trait Schema[+T]
    
    trait Writes[+T]
    
    trait SchemaHelper[+T] {
      def schema: Schema[T]
      def writes: Writes[T]
    }
    
    implicit def writesFromHelper[T](implicit c: SchemaHelper[T]): Writes[T] = c.writes
    implicit def schemaFromHelper[T](implicit c: SchemaHelper[T]): Schema[T] = c.schema
    
    case class Foo(id: Int, name: String)
    implicit val intHelper: SchemaHelper[Int] = new SchemaHelper[Int] {
      def schema: Schema[Int] = ???
      def writes: Writes[Int] = ???
    }
    implicit val fooHelper: SchemaHelper[Foo] = new SchemaHelper[Foo] {
      def schema: Schema[Foo] = ???
      def writes: Writes[Foo] = ???
    }
    
    implicitly[SchemaHelper[Foo]]
    implicitly[Writes[Foo]]
    implicitly[Schema[Foo]]
    

    https://scastie.scala-lang.org/DmytroMitin/uICRbhWvSUWrfgnZ3SHZvQ

    compiles in 2.13.12 and

    trait Schema[T]
    
    trait Writes[T]
    
    trait SchemaHelper[T] {
      def schema: Schema[T]
      def writes: Writes[T]
    }
    
    ...
    

    https://scastie.scala-lang.org/DmytroMitin/uICRbhWvSUWrfgnZ3SHZvQ/1

    compiles but

    trait Schema[+T]
    
    trait Writes[T]
    
    trait SchemaHelper[T] {
      def schema: Schema[T]
      def writes: Writes[T]
    }
    
    ...
    

    https://scastie.scala-lang.org/DmytroMitin/uICRbhWvSUWrfgnZ3SHZvQ/2

    doesn't.

    This is fixed in Scala 3: https://scastie.scala-lang.org/DmytroMitin/uICRbhWvSUWrfgnZ3SHZvQ/3

    A workaround is to introduce an "invariant" type alias for Schema[T]

    type Schema[T] = json.Schema[T] // NOT type Schema[+T] = json.Schema[T]
    

    https://scastie.scala-lang.org/DmytroMitin/GBTHb10wT0iV9jK4dwg79A/3

    (I'm writing "invariant" in quotes because the alias type Schema[T] is still covariant, implicitly[Schema[t1] <:< Schema[t2]] for t1 <: t2: https://scastie.scala-lang.org/DmytroMitin/GBTHb10wT0iV9jK4dwg79A/8)