Skip to Content
Course content

92: Enums in Scala 3 In Depth

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

I've spent a lot of time in Scala 2 dealing with "sealed trait" hierarchies to simulate what other languages call Enums. It worked, but it was boilerplate-heavy. Scala 3 finally gave us a first-class enum keyword, and while it looks simple on the surface, there's a lot happening under the hood that we should poke at together.

Just a list of states

Let's start with something basic. Imagine we're building a package tracking system. At first, I just need a few statuses. I'll try the simplest form of an enum:

enum DeliveryStatus {
  case Pending, Shipped, Delivered, Returned
}

I can use this in a match statement, and it feels great. The compiler knows exactly which cases are possible. If I forget Returned, the compiler yells at me. That's the primary win here. But wait—what if I want to print these out or iterate through them? In the old days, I'd have to manually create a List of all possible values. Let's see if Scala 3 helps us out:

val allStatuses = DeliveryStatus.values
println(allStatuses) 
// Output: Array(Pending, Shipped, Delivered, Returned)

That's handy. DeliveryStatus.values is automatically provided. It's a simple array of all the cases. This is the "pure" enumeration use case where every member is effectively a singleton.

Adding metadata to the status

Now, real-world requirements usually change five minutes after you finish the code. My boss tells me that when a package is Returned, we need to know the reason. Maybe it was "Damaged" or "Wrong Address".

If I try to just add a parameter to the Returned case like this: case Returned(reason: String), I might expect it to just work. Let's try it:

enum DeliveryStatus {
  case Pending, Shipped, Delivered
  case Returned(reason: String)
}

This compiles! But something interesting happened. I just turned my simple enumeration into a full-blown Algebraic Data Type (ADT). If I try to call DeliveryStatus.values now, the compiler will actually throw an error. Why? Because Returned is no longer a singleton; it's a case class. You can't have a static list of "all values" when one of those values can be any arbitrary string.

I've essentially shifted from a "Closed Set of Values" to a "Closed Set of Types". This is a critical distinction in Scala 3.

Mixing constants and data

Since we now have a mix of singletons (like Pending) and data-carrying cases (like Returned), my pattern matching has to evolve. I can't just treat them as simple constants anymore.

def getStatusMessage(status: DeliveryStatus): String = status match {
  case DeliveryStatus.Pending => "Your package is waiting."
  case DeliveryStatus.Shipped => "It's on the way!"
  case DeliveryStatus.Delivered => "Package arrived."
  case DeliveryStatus.Returned(reason) => s"Returned because: $reason"
}

I noticed that for the simple cases, I'm still using DeliveryStatus.Pending. But wait, if I'm inside a context where the compiler knows I'm dealing with DeliveryStatus, I can actually just use Pending if I import them. But more importantly, I can add custom methods directly inside the enum. This is where the "In Depth" part comes in—the enum isn't just a data structure; it's a class.

enum DeliveryStatus {
  case Pending, Shipped, Delivered, Returned(reason: String)

  def isFinalState: Boolean = this match {
    case Delivered | Returned(_) => true
    case _ => false
  }
}

I love this because the logic stays with the data. I don't have to write a separate "StatusUtils" class that feels like it belongs in 2005.

The hidden power of parameters

One last thing I wanted to experiment with: what if all the statuses need a shared property, like a priority level, but they don't all take unique data? I can actually define a constructor for the enum itself.

enum DeliveryStatus(val priority: Int) {
  case Pending extends DeliveryStatus(1)
  case Shipped extends DeliveryStatus(2)
  case Delivered extends DeliveryStatus(3)
  case Returned extends DeliveryStatus(1)
}

By adding (val priority: Int) to the enum definition, I've forced every case to provide that value. Now I can do status.priority without any pattern matching at all. It's a clean way to attach metadata to every possible state without duplicating the field in every single case class.




📋 Practical Task

Implement a Payment Method Hierarchy

You are building a checkout system. Create an enum called PaymentMethod that handles three different scenarios:

  • Cash: A simple state with no extra data.
  • CreditCard: Needs to store a cardNumber (String) and an expiryDate (String).
  • PayPal: Needs to store an email (String).

Additionally, implement the following requirements:

  1. Add a method inside the enum called processPayment(amount: Double).
    • For Cash, return: "Paid $amount in cash".
    • For CreditCard, return: "Charged $amount to card ending in [last 4 digits of card]".
    • For PayPal, return: "Requested $amount from [email]".
  2. In your main code, create a List containing one of each payment method and use .foreach to print the result of processPayment(100.0) for each.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.