6
votes

I'm trying to copy() a Scala case class which has a type param. At the call site, the type of the value is Foo[_].

This compiles as expected:

case class Foo[A](id: String, name: String, v1: Bar[A])
case class Bar[A](v: A)

val foo: Foo[_] = Foo[Int]("foo1", "Foo 1", Bar[Int](1))

foo.copy(id = "foo1.1")

But if I add another member of type Bar[A], it doesn't compile anymore:

case class Foo[A](id: String, name: String, v1: Bar[A], v2: Bar[A])
case class Bar[A](v: A)

val foo: Foo[_] = Foo[Int]("foo1", "Foo 1", Bar[Int](1), Bar[Int](2))

foo.copy(id = "foo1.1") // compile error, see below
type mismatch;
 found   : Playground.Bar[_$1]
 required: Playground.Bar[Any]
Note: _$1 <: Any, but class Bar is invariant in type A.
You may wish to define A as +A instead. (SLS 4.5)
Error occurred in an application involving default arguments

Scastie

So far I found two workarounds:

  • Make Bar covariant in A, then the problem hides itself because now Bar[_$1] <: Bar[Any]
  • Define a copyId(newId: String) = copy(id = newId) method on Foo and call that instead, then we aren't calling copy on a value of type Foo[_].

However, neither of those are really feasible for my use case, Bar should be invariant, and I have too many different copy calls on Foo[_] instances to make copyThisAndThat methods for them all.

I guess my real question is, why is Scala behaving this way? Seems like a bug tbh.

1
What is the point of having a type parameter if you are going to forget about it latter? Maybe you can redesign some part of your model to fix this problem. - Luis Miguel Mejía Suárez
@LuisMiguelMejíaSuárez I minimized the use case for compactness. I am only "forgetting" about the type param in contexts where I'm updating members that have nothing to do with that type parameter, such as id or name. I usually don't have any information available to me about the type param in such cases, thus the existential type at call site. But I do need the type param in other contexts when actually working with v1 and v2 members. - Nikita
Why not having two case classes, one for the info that is not generic and the other for the generics. And a third class that composes of those two? - Luis Miguel Mejía Suárez
Because I don't want three classes where my domain says there should be one entity? I don't want to create meaningless classes just to get around a compiler quirk. - Nikita
Well the compiler quirk was really the existential in the first place (e.g. Foo[_]) those are tricky and will be reworked in Scala 3. One entity can be composed of may entities, it is really not that different for saying your case class has n fields. Also, you mentioned that you have a long code path were you do not care about the other fields, that is a good indication that you actually have more entities than one. Anyways, I just proposed a simple workaround, if you prefer to do a strange pattern match everywhere go ahead :) - Luis Miguel Mejía Suárez

1 Answers

7
votes

After the compiler handles named and default parameters, the calls become

foo.copy("foo1.1", foo.name, foo.v1)

and

foo.copy("foo1.1", foo.name, foo.v1, foo.v2)

respectively. Or, if you replace the parameters with types,

foo.copy[?](String, String, Bar[_])

and

foo.copy[?](String, String, Bar[_], Bar[_])

? is the type parameter of copy which has to inferred. In the first case the compiler basically says "? is the type parameter of Bar[_], even if I don't know what that is".

In the second case the type parameters of two Bar[_] must really be the same, but that information is lost by the time the compiler is inferring ?; they are just Bar[_], and not something like Bar[foo's unknown type parameter]. So e.g. "? is the type parameter of first Bar[_], even if I don't know what that is" won't work because so far as the compiler knows, the second Bar[_] could be different.

It isn't a bug in the sense that it follows the language specification; and changing the specification to allow this would take significant effort and make both it and the compiler more complicated. It may not be a good trade-off for such a relatively rare case.

Another workaround is to use type variable pattern to temporarily give a name to _:

foo match { case foo: Foo[a] => foo.copy(id = "foo1.1") }

The compiler now sees that foo.v1 and foo.v2 are both Bar[a] and so the result of copy is Foo[a]. After leaving the case branch it becomes Foo[_].