Field Notes

Learning enumeratum, part 1: why not just use scala.Enumeration?

Learning enumeratum, part 1: why not just use scala.Enumeration?

At work, the Scala codebases I touch use enumeratum almost everywhere a value comes from a fixed set: statuses, channels, currencies, event types. I'd read it and used it without ever asking why we don't just use what Scala gives us. This series is me fixing that, one small lesson at a time, with runnable code.

All the code lives in a public repo: zakariahere/learning-enumeratum. Each lesson is its own package. This first one is lesson1. The setup is Scala 2.13.18, sbt 1.11.7 and enumeratum 1.9.0.

The setup

ThisBuild / scalaVersion := "2.13.18" libraryDependencies += "com.beachape" %% "enumeratum" % "1.9.0"

That one dependency is the core library. The integrations (circe, play-json, doobie and so on) are separate modules, and I'll get to them later in the series.

Option 1: scala.Enumeration

This is the enum that ships with the standard library:

object ColorEnum extends Enumeration {
  type ColorEnum = Value
  val Red, Green, Blue = Value
}

It's short, and you get values and withName for free. The problem is the type: Red, Green and Blue are all just ColorEnum.Value. A value can't have its own type, its own fields or its own methods without awkward workarounds. withName also throws NoSuchElementException on unknown input, and you can't ask it for an Option.

I expected the compiler to stay silent when a match missed a case. I wrote one that left out Blue to prove it, and Scala 2.13 surprised me:

[warn] PlainScala.scala:32:44: match may not be exhaustive.
[warn] It would fail on the following input: (x: lesson1.ColorEnum.ColorEnum forSome x not in (Green, Red))

So on 2.13, a match on Enumeration that misses a case does get a warning. Scala 2.12 and earlier don't do this, and it's still only a warning about one shared type. The rest of the problems remain.

Option 2: sealed trait and case objects

This is the idiomatic ADT way, and what most Scala developers reach for first:

sealed trait Color
object Color {
  case object Red   extends Color
  case object Green extends Color
  case object Blue  extends Color   val values: List[Color] = List(Red, Green, Blue)
  def fromString(s: String): Option[Color] = values.find(_.toString == s)
}

Every value now has its own type, the compiler checks every match covers all cases, and each value can carry fields and methods. What's left is chores. values is a list I keep up to date by hand. If I add Yellow and forget the list, nothing fails to compile, and every lookup quietly ignores the new value. String parsing is code I'd have to write again for every enum in the codebase.

Option 3: enumeratum

enumeratum keeps option 2 as it is and removes the chores:

An enumeratum enum: sealed trait Fruit extends EnumEntry, a companion object extending Enum[Fruit] with values = findValues, three case objects, and the withNameOption and withNameInsensitiveOption calls
The trait extends EnumEntry, the companion object extends Enum, and findValues builds the list when the code compiles.

Two changes: the trait extends EnumEntry, and the companion object extends Enum[Fruit]. findValues is a macro. When the code compiles, it collects every member of Fruit declared inside the companion object. Adding a case object adds it to values, with no list to forget. This is the actual output of the demo in the repo:

values           : Vector(Apple, Banana, Cherry)
withName         : Apple
withNameOption   : None
withNameInsensitive: Some(Banana)
entryName        : Cherry
indexOf          : 1

Because it's still a sealed trait, exhaustive match checking works the same as in option 2. enumeratum doesn't replace the ADT approach. It's the ADT approach with the repetitive parts generated.

Side by side

Comparison table of scala.Enumeration, sealed trait with case objects, and enumeratum across own type per value, exhaustive match, values, string parsing, case-insensitive parsing, and fields and methods
enumeratum keeps everything the sealed trait gives you and adds what Enumeration got right: values and parsing for free.

My takeaway for now: Enumeration is fine for a throwaway script. A plain sealed trait is fine when no one ever needs to list the values or parse them from a string. In a backend where values arrive as strings in JSON, from the database or from Kafka, you need both. That's why my team uses enumeratum, and it now makes sense to me.

Run it yourself

git clone https://github.com/zakariahere/learning-enumeratum
cd learning-enumeratum
sbt "runMain lesson1.PlainScalaDemo" "runMain lesson1.EnumeratumDemo"

Next up

Custom names and enums with fields: overriding entryName when the database or JSON spelling doesn't match the Scala name, values that carry data, and value enums like IntEnum and StringEnum.

Keep reading