The code fragment provided is a made-up minimalistic example just to demonstrate the issue, not related to actual business logic types.
In the code below we have a nested Entry
type inside Registry
type.
class Registry[T](name: String) {
case class Entry(id: Long, value: T)
}
That makes sense cause Entries of different Registries are kind of different, incomparable types.
Then we may have an implicit Ops class, for example, used in tests, which binds our registries to some test storage implementation, a simple mutable map
object testOps {
import scala.collection.mutable
type TestStorage[T] = mutable.Map[Long, T]
implicit class RegistryOps[T](val self: Registry[T])(
implicit storage: TestStorage[T]
) {
def getById(id: Long): Option[self.Entry] =
storage.get(id).map(self.Entry(id, _))
def remove(entry: self.Entry): Unit = storage - entry.id
}
}
The problem is: the Entry
consructed inside Ops wrapper is treated as an incomparable type to the original Registry object
object problem {
case class Item(name: String)
val items = new Registry[Item]("elems")
import testOps._
implicit val storage: TestStorage[Item] =
scala.collection.mutable.Map[Long, Item](
1L -> Item("whatever")
)
/** Compilation error:
found : _1.self.Entry where val _1: testOps.RegistryOps[problem.Item]
required: eta$0$1.self.Entry
*/
items.getById(1).foreach(items.remove)
}
The question is: Is there a way to declare Ops signatures to make compiler understand that we're working with same inner type? (
I've also tried self.type#Entry
in RegistryOps
with no luck)
If I miss some understanding and they are actually different types, I would appreciate any explanations and examples why considering them as same may break type system. Thanks!
To start off, it's worth noting that the implicitness here isn't really the issue—if you wrote out something like the following, it would fail in exactly the same way:
new RegistryOps(items).getById(1).foreach(e => new RegistryOps(items).remove(e))
There are ways to do the kind of thing you want to do, but they aren't really pleasant. One would be to desugar the implicit class so that you can have it capture a more specific type for the registry value:
class Registry[T](name: String) {
case class Entry(id: Long, value: T)
}
object testOps {
import scala.collection.mutable
type TestStorage[T] = mutable.Map[Long, T]
class RegistryOps[T, R <: Registry[T]](val self: R)(
implicit storage: TestStorage[T]
) {
def getById(id: Long): Option[R#Entry] =
storage.get(id).map(self.Entry(id, _))
def remove(entry: R#Entry): Unit = storage - entry.id
}
implicit def toRegistryOps[T](s: Registry[T])(
implicit storage: TestStorage[T]
): RegistryOps[T, s.type] = new RegistryOps[T, s.type](s)
}
This works just fine, either in the form you're using it, or slightly more explicitly:
scala> import testOps._
import testOps._
scala> case class Item(name: String)
defined class Item
scala> val items = new Registry[Item]("elems")
items: Registry[Item] = Registry@69c1ea07
scala> implicit val storage: TestStorage[Item] =
| scala.collection.mutable.Map[Long, Item](
| 1L -> Item("whatever")
| )
storage: testOps.TestStorage[Item] = Map(1 -> Item(whatever))
scala> val resultFor1 = items.getById(1)
resultFor1: Option[items.Entry] = Some(Entry(1,Item(whatever)))
scala> resultFor1.foreach(items.remove)
Note that the inferred static type of resultFor1
is exactly what you'd expect and want. Unlike the Registry[T]#Entry
solution proposed in a comment above, this approach will prohibit you from taking an entry from one registry and removing it from another with the same T
. Presumably you made Entry
an inner case class specifically because you wanted to avoid that kind of thing. If you don't care you really should just promote Entry
to its own top-level case class with its own T
.
As a side note, you might think that it would work just to write the following:
implicit class RegistryOps[T, R <: Registry[T]](val self: R)(
implicit storage: TestStorage[T]
) {
def getById(id: Long): Option[R#Entry] =
storage.get(id).map(self.Entry(id, _))
def remove(entry: R#Entry): Unit = storage - entry.id
}
But you'd be wrong, because the synthetic implicit conversion method the compiler will produce when it desugars the implicit class will use a wider R
than you need, and you'll be back in the same situation you had without the R
. So you have to write your own toRegistryOps
and specify s.type
.
(As a footnote, I have to say that having some mutable state that you're passing around implicitly sounds like an absolute nightmare, and I'd strongly recommend not doing anything remotely like what you're doing here.)