Type parameter under self-type doesn't conform to upper bound despite evidence
Asked Answered
A

2

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?

Albania answered 9/9, 2022 at 4:46 Comment(1)
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.Furnary
V
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.

Victoria answered 9/9, 2022 at 8:0 Comment(5)
"generalized type constraints can only be applied to generics": So the existential type in Job[_] prevents that from applicable?Albania
I guess generalized type constraints can be applied to any types including type members.Paternity
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.Victoria
I updated the answer to explain the issue.Victoria
<: 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: blog.bruchez.name/posts/generalized-type-constraints-in-scala stackoverflow.com/questions/52660723 stackoverflow.com/questions/57460447 stackoverflow.com/questions/57700168 stackoverflow.com/questions/58892610 stackoverflow.com/questions/59231119 stackoverflow.com/questions/62510398Paternity
S
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.

Submariner answered 9/9, 2022 at 14:31 Comment(2)
Clean and simple. I like it! I'd be curious to find out what trouble you had using existential types.Victoria
@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.Inadvertent

© 2022 - 2025 — McMap. All rights reserved.