Field Notes

Learning enumeratum, part 2: custom names, fields and value enums

Learning enumeratum, part 2: custom names, fields and value enums

In part 1 I compared scala.Enumeration, plain sealed traits and enumeratum, and saw why enumeratum wins once values have to be listed and parsed. Real values rarely look as tidy as Apple and Banana, though. The string in the database doesn't match the Scala name, a currency needs a symbol, and a priority is stored as a number. This lesson covers those three cases.

The code is in the lesson2 package of zakariahere/learning-enumeratum.

When the stored string isn't the Scala name

By default an entry's entryName is its toString, which is the object's name. Every lookup (withName, withNameOption and the rest) matches on entryName. When a legacy system spells something its own way, override it:

sealed abstract class PaymentStatus(override val entryName: String) extends EnumEntry

object PaymentStatus extends Enum[PaymentStatus] {
  val values: IndexedSeq[PaymentStatus] = findValues

  case object Pending   extends PaymentStatus("PENDING")
  case object Settled   extends PaymentStatus("SETTLED")
  case object Cancelled extends PaymentStatus("CANCELED_BY_USER") // legacy spelling
}

The output is the part worth remembering:

PaymentStatus values  : Vector(PENDING, SETTLED, CANCELED_BY_USER)
withName CANCELED...  : Some(Cancelled)
withName 'Cancelled'  : None
toString vs entryName : Cancelled / CANCELED_BY_USER

After the override, the Scala name no longer parses. withNameOption("Cancelled") returns None. That's the right behaviour, because there's exactly one external spelling, but it's easy to trip over in a test. toString still returns the Scala name, so logs and the wire format can now differ.

Or let a naming mixin do it

Writing a string per entry is fine for one odd case. When a whole enum follows one convention, enumeratum has mixins in EnumEntry:

import enumeratum.EnumEntry._

sealed trait Channel extends EnumEntry with UpperSnakecase

object Channel extends Enum[Channel] {
  val values: IndexedSeq[Channel] = findValues

  case object MobileApp    extends Channel // MOBILE_APP
  case object WebBanking   extends Channel // WEB_BANKING
  case object BranchOffice extends Channel // BRANCH_OFFICE
}
Channel entryNames    : Vector(MOBILE_APP, WEB_BANKING, BRANCH_OFFICE)
withName MOBILE_APP   : Some(MobileApp)

There are mixins for the usual styles (Snakecase, Hyphencase, Lowercase, Uppercase, Camelcase and more). No strings to type means no typos to make.

Three cards: overriding entryName for one legacy string, a naming mixin such as UpperSnakecase for a whole enum, and a value enum whose identity is a code
Pick by how the outside world names things: one exception, one convention, or a code.

Entries that carry data

Entries are ordinary classes, so they can take constructor parameters and have methods. This is where the sealed-trait approach pulls away from Enumeration:

sealed abstract class Currency(val symbol: String, val decimals: Int) extends EnumEntry {
  def format(minorUnits: Long): String =
    symbol + BigDecimal(minorUnits, decimals).toString
}

object Currency extends Enum[Currency] {
  val values: IndexedSeq[Currency] = findValues

  case object EUR extends Currency("€", 2)
  case object USD extends Currency("$", 2)
  case object JPY extends Currency("¥", 0)

  val bySymbol: Map[String, Currency] = values.map(c => c.symbol -> c).toMap
}
€1234.56
¥5000
bySymbol("$") : Some(USD)

Two things I like here. The number of decimals sits next to the currency instead of in a match somewhere else. And because values is always complete, derived indexes like bySymbol are a single line that can't go stale.

My first version formatted the amount with an f"..." string whose width came from decimals. It didn't compile, because the f interpolator checks its format string at compile time and needs a fixed width. BigDecimal(unscaled, scale) is the better tool for money anyway.

Value enums: when the identity is a code

Sometimes the name doesn't matter at all. A priority is stored as 10 and a country as "LU". For that, enumeratum.values has IntEnum, StringEnum, LongEnum and friends. Entries declare a value and lookups use it:

import enumeratum.values._

sealed abstract class Priority(val value: Int) extends IntEnumEntry

object Priority extends IntEnum[Priority] {
  val values: IndexedSeq[Priority] = findValues

  case object Low    extends Priority(1)
  case object Medium extends Priority(5)
  case object High   extends Priority(10)
}

sealed abstract class Country(val value: String, val label: String) extends StringEnumEntry

object Country extends StringEnum[Country] {
  val values: IndexedSeq[Country] = findValues

  case object Luxembourg extends Country("LU", "Luxembourg")
  case object France     extends Country("FR", "France")
  case object Belgium    extends Country("BE", "Belgium")
}
Priority.withValue(5)       : Medium
Priority.withValueOpt(7)    : None
Country.withValueOpt("LU")  : Some(Luxembourg)
Country values              : Vector(LU, FR, BE)

The method names change from withName to withValue and withValueOpt. Note that it's Opt here, not Option.

Duplicate values fail to compile

This is the feature that sold me. For value enums, findValues also checks that every value is unique. I added a second entry with the value 10 to check:

Two case objects High and Urgent both extending Priority(10), and the sbt compile error saying the ValueEnum does not have unique values
Two entries mapped to the same code is a bug the compiler catches, not something you find in production.

With a plain sealed trait, two entries sharing a database code would compile fine, and one of them would silently win every lookup. Here the build fails.

What I'd use when

  • Default entryName when you control both sides and the Scala name is fine on the wire.
  • A naming mixin when the outside world uses one convention, such as UPPER_SNAKE in a database.
  • An entryName override for the one legacy spelling that breaks the rules.
  • A value enum when the stored identity is a number or a short code and the name is only for humans.

Run it yourself

git clone https://github.com/zakariahere/learning-enumeratum
cd learning-enumeratum
sbt "runMain lesson2.CustomNamesDemo" "runMain lesson2.WithFieldsDemo" "runMain lesson2.ValueEnumsDemo"

Next up

Getting enums in and out of the system: JSON with circe, and how the codecs respect entryName and value.

Keep reading