0

I have a trait with a self-type annotation that has a type parameter. This trait is from a library and cannot be modified. I want to pass this trait to a function that will require an upper bound for the type parameter. For example, I have this code snippet:

sealed trait Job[K] { self =>
  type T
}

case class Encoder[T <: Product]()

def encoder(job: Job[_])(implicit ev: job.T <:< Product): Encoder[job.T] =
  new Encoder[job.T]()

This returns an error that Type argument job.T does not conform to upper bound Product and a warning that ev is never used. How should I design the encoder function?

yhylord
  • 430
  • 4
  • 13
  • 3
    With the type `Job[_]` you are throwing away the dependent type `T`, it is nowhere in the type signature of the input. Something like `Job.Aux[_, T]`, or the structural type in the answer below would be needed. – V-Lamp Sep 09 '22 at 08:08

2 Answers2

3

Why it doesn't work?

Your issue has nothing to do with the generalized type constraint. You can remove it and still get the same error. A generalized type constraint is used to constrain the type of arguments the method can receive.

(implicit ev: job.T <:< Product) provides an evidence in scope that matches only if job.T <: Product, allowing only calls to the method with Job arguments where job.T <: Product. This is its purpose.

Your issue is because the Encoder class has its type parameter T <: Product. The generalized type constraint does not treat the type job.T itself as a subtype of Product, as you expected. The evidence only applies to value arguments, not to the type itself, because this is how implicit conversions work.

For example, assuming a value x of type job.T that can be passed to the method as an argument:

  def encoder(job: Job[_])(x: job.T)(implicit ev: job.T <:< Product): Unit = {
    val y: Product          = x // expands to: ev.apply(x) 
    val z: Encoder[Product] = new Encoder[job.T] // does not compile
  }

The first line compiles because x is expanded to ev.apply(x), but the second one cannot be expanded, regardless if Encoder is covariant or not.

First workaround

One workaround you can do is this:

  def encoder[U <: Product](job: Job[_])(implicit ev: job.T <:< Product): Encoder[U] =
    new Encoder[U]()

The problem with this is that while both type parameters U and T are subtypes of Product, this definition does not says much about the relation between them, and the compiler (and even Intellij) will not infer the correct resulting type, unless you specify it explicitly. For example:

  val myjob = new Job[Int] {
    type T = (Int, Int)
  }

  val myencoder: Encoder[Nothing]     = encoder(myjob) // infers type Nothing
  val myencoder2: Encoder[(Int, Int)] = encoder[(Int, Int)](myjob) // fix

But why use job.T <:< Product if we already have U <: Product. We can instead use the =:= evidence to make sure their types are equal.

  def encoder[U <: Product](job: Job[_])(implicit ev: job.T =:= U): Encoder[U] =
    new Encoder[U]()

Now the resulting type will be correctly inferred.

Second workaround

A shorter workaround is using a structural type instead:

  def encoder(job: Job[_] { type T <: Product }): Encoder[job.T] =
    new Encoder[job.T]()

Which is not only cleaner (doesn't require a generalized type constraint), but also avoids the earlier problem.

Both versions work on Scala 2.13.8.

Alin Gabriel Arhip
  • 2,568
  • 1
  • 14
  • 24
  • "generalized type constraints can only be applied to generics": So the existential type in `Job[_]` prevents that from applicable? – yhylord Sep 09 '22 at 13:57
  • 1
    I guess generalized type constraints can be applied to any types including type members. – Dmytro Mitin Sep 09 '22 at 14:39
  • I stand corrected. Generalized type constraints can be applied to any types. I think the issue is the place where you are using it. Generalized type constraints are just implicit conversions that get applied to *values* of the mentioned types found in your method, not to the types directly. – Alin Gabriel Arhip Sep 09 '22 at 17:51
  • 1
    I updated the answer to explain the issue. – Alin Gabriel Arhip Sep 09 '22 at 18:05
  • 1
    `<:` relates to type inference, `<:<` relates to implicit resolution. Type inference and implicit resolution make impact to each other but are different processes. Other cases when difference `<:` vs. `<:<` is important: https://blog.bruchez.name/posts/generalized-type-constraints-in-scala/ https://stackoverflow.com/questions/52660723 https://stackoverflow.com/questions/57460447 https://stackoverflow.com/questions/57700168 https://stackoverflow.com/questions/58892610 https://stackoverflow.com/questions/59231119 https://stackoverflow.com/questions/62510398 – Dmytro Mitin Sep 10 '22 at 12:54
2

Expanding on Alin's answer, you may also use a type alias to express the same thing like this:

type JobProduct[K, P <: Product] = Job[K] { type T = P }

// Here I personally prefer to use a type parameter rather than an existential
// since I have had troubles with those, but if you don't find issues you may just use
// JobProdut[_, P] instead and remove the K type parameter.
def encoder[K, P <: Product](job: JobProduct[K, P]): Encoder[P] =
  new Encoder[P]()

This approach may be more readable to newcomers and allows reuse; however, is essentially the same as what Alin did.

  • Clean and simple. I like it! I'd be curious to find out what trouble you had using existential types. – Alin Gabriel Arhip Sep 09 '22 at 18:09
  • 1
    @AlinGabrielArhip compilation errors, type mismatches, that kind of stuff. I don't remember of a specific one on top of my head and they may be related to other mistakes since I remember those mostly from my early days. Still, an extra type parameter does not hurt anyone and one can be safe type inference will just work as expected. – Luis Miguel Mejía Suárez Sep 09 '22 at 19:22