Scala case class copy doesn't always work with `_` existential type
Asked Answered
H

1

6

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.

Heber answered 13/7, 2020 at 10:15 Comment(5)
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.Glycogenesis
@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.Heber
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?Glycogenesis
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.Heber
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 :)Glycogenesis
K
7

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[_].

Khalil answered 13/7, 2020 at 11:20 Comment(1)
Great answer, thanks! Makes sense now. I should be able to make use of that match trick in some of my uses cases at least.Heber

© 2022 - 2024 — McMap. All rights reserved.