Skip to Content
Course content

133: Existential Types in Scala 2

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen a lot of developers hit a wall when they try to create collections of generic objects. You've got a generic class—say, a Repository[T]—and you want a list of repositories for different entities: one for User, one for Order, and one for Product. Naturally, you try to put them in a List[Repository[_]]. This is where you first encounter existential types in Scala 2, and it's usually where the frustration begins.

The frustration of the wildcard

When you use the underscore in Repository[_], you're telling the compiler: "I have a repository of some type, but I'm not telling you which one." In formal terms, this is an existential type. It essentially says "there exists some type T such that this is a Repository of T."

trait Repository[T] {
  def save(item: T): Unit
  def find(id: String): T
}

class UserRepository extends Repository[User] {
  def save(item: User): Unit = println(s"Saving user ${item.name}")
  def find(id: String): User = new User(id, "John Doe")
}

class OrderRepository extends Repository[Order] {
  def save(item: Order): Unit = println(s"Saving order ${item.orderId}")
  def find(id: String): Order = new Order(id, 100.0)
}

val repos: List[Repository[_]] = List(new UserRepository, new OrderRepository)

At first glance, this looks fine. But the moment you try to actually use a repository from that list, you'll run into a wall. If you try to call repos.head.save(someUser), the compiler will stop you. Why? Because the compiler knows repos.head is a Repository of some type, but it has no guarantee that the "some type" is User. It could be Order. To the compiler, you're trying to pass a User into a function that expects a type it can't identify.

Anchoring types to instances

The "better" way to handle this in Scala 2—when you need to group these objects but still maintain a relationship between the container and the item—is to move the type from a class parameter to an abstract type member. This shifts the perspective from "this class is parameterized by T" to "this instance has a specific type associated with it."

trait Store {
  type Entity
  def save(item: Entity): Unit
  def find(id: String): Entity
}

class UserStore extends Store {
  type Entity = User
  def save(item: User): Unit = println(s"Saving user ${item.name}")
  def find(id: String): User = new User(id, "John Doe")
}

class OrderStore extends Store {
  type Entity = Order
  def save(item: Order): Unit = println(s"Saving order ${item.orderId}")
  def find(id: String): Order = new Order(id, 100.0)
}

val stores: List[Store] = List(new UserStore, new OrderStore)

Now we have a List[Store]. We've effectively hidden the type inside the trait. But wait, you might think, "Doesn't this have the same problem as the wildcard?" Not exactly. The magic here is path-dependent types. The type Entity is now tied to the specific instance of the Store.

If you have a reference to a specific store, you can use its own Entity type to make the compiler happy. You can't just pass a random User into stores.head.save(), but you can take the result of stores.head.find("123") and pass it right back into stores.head.save(). The compiler knows that the type returned by find is the exact same existential type required by save for that specific instance.

The cost of losing the generic

So why not always do this? There's a trade-off. When you move from Repository[T] to a Store with a type Entity, you lose the ability to constrain the type from the outside. With Repository[User], I can tell the world exactly what this repository handles. With Store, I'm essentially saying "this handles something," and you have to interact with the instance to figure out what that something is.

I generally recommend using generics (type parameters) for your primary API and logic. Use abstract type members (existentials) when you're building heterogeneous collections or plugin architectures where you need to treat different implementations uniformly, but still need those implementations to remain internally consistent with their own types.




📋 Practical Task

Implement a Heterogeneous Plugin Registry

You are building a plugin system where each plugin has its own unique Configuration type. You need to be able to store a list of plugins and update their configurations without knowing the specific configuration type of each plugin at compile time.

Requirements:

  • Create a trait Plugin that uses an abstract type member type Config.
  • The Plugin trait should have a method applyConfig(config: Config): Unit and a method getCurrentConfig: Config.
  • Implement two plugins: DatabasePlugin (using a DbConfig case class) and ApiPlugin (using an ApiConfig case class).
  • Create a List[Plugin] containing both.
  • Write a function refreshPlugins(plugins: List[Plugin]) that iterates through the list, retrieves the current config for each plugin, and immediately passes that same config back into applyConfig to "reset" it.

Hint: The compiler will only allow the refreshPlugins logic if you use the return value of getCurrentConfig directly as the argument for applyConfig on the same instance.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.