Field Notes

Learning enumeratum, part 3: enums in and out of JSON with circe

Learning enumeratum, part 3: enums in and out of JSON with circe

In part 2 I covered how an enum entry gets its name: the default entryName, an override, a naming mixin, or a value for value enums. I said that choice decides what the outside world sees. This lesson makes that concrete with JSON, because in the services I work on, most enum values arrive from or leave through a JSON payload.

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

The dependency

The circe integration is a separate module from the core library. I added circe's generic derivation and parser alongside it:

libraryDependencies ++= Seq(
  "com.beachape" %% "enumeratum"       % "1.9.0",
  "com.beachape" %% "enumeratum-circe" % "1.9.0",
  "io.circe"     %% "circe-generic"    % "0.14.10",
  "io.circe"     %% "circe-parser"     % "0.14.10"
)

One mixin per enum

For a name-based enum, mix CirceEnum into the companion object. For a value enum, use the matching variant: IntCirceEnum, StringCirceEnum, LongCirceEnum and so on. That's the whole setup. Each mixin puts an implicit Encoder and Decoder in the companion object, where circe will find them.

sealed trait Channel extends EnumEntry with UpperSnakecase

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

  case object MobileApp  extends Channel
  case object WebBanking extends Channel
}

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

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

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

A case class that uses both enums can be derived as usual. The derivation finds the enum codecs through implicit search, with nothing to import or wire by hand:

final case class Payment(id: String, channel: Channel, priority: Priority)

object Payment {
  implicit val encoder: Encoder[Payment] = deriveEncoder
  implicit val decoder: Decoder[Payment] = deriveDecoder
}
enum alone      : "WEB_BANKING"
value enum alone: 5
payload         : {"id":"pay-42","channel":"MOBILE_APP","priority":10}
decode ok       : Right(Payment(pay-1,WebBanking,Medium))

That output answers the question I had after part 2. Channel is written as "MOBILE_APP" because the UpperSnakecase mixin sets entryName, and the codec uses entryName. Priority is written as the number 10, not as "High", because a value enum's identity is its value.

Table of codecs: CirceEnum writes entryName, IntCirceEnum writes the Int value, a derived case class codec finds both, and decodeCaseInsensitive accepts any casing
The JSON is always entryName or value. toString never reaches the wire.

Decoding is strict, and that's good

The more interesting half is what happens with bad input. I fed the decoder the three mistakes I expect to see from real upstream systems: the Scala name instead of the wire name, an unknown code, and the right name in the wrong case.

Three decode calls returning Left(DecodingFailure): the Scala name WebBanking, the unknown priority 7, and lowercase mobile_app, each with the JSON path of the failing field
Each failure is a Left with the exact JSON path, not an exception and not a silent default.

Three things to notice:

  • The Scala name doesn't decode. "WebBanking" fails because the wire name is WEB_BANKING. This is the same rule as withName in part 2: lookups match entryName only.
  • The failure points at the field. DecodingFailure at .channel and at .priority tell you which field broke, which matters when a payload has twenty of them.
  • It's a value, not an exception. decode returns an Either, so a bad message from a queue becomes a Left you can log, count or send to a dead-letter topic.

One small wart: the message prints the enum object as lesson3.Channel$@25a2c2e, an object identity hash rather than a readable name. It's fine in a log line, but I wouldn't return that text to an API client as is.

When an upstream system is messy

Sometimes you don't control the producer, and it sends "core_banking" on Monday and "CORE_BANKING" on Tuesday. Instead of mixing in CirceEnum, you can declare the codecs yourself with the helpers in enumeratum.Circe:

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

  case object CoreBanking extends Source
  case object CardSystem  extends Source

  implicit val encoder: Encoder[Source] = enumeratum.Circe.encoder(this)
  implicit val decoder: Decoder[Source] = enumeratum.Circe.decodeCaseInsensitive(this)
}
case-insensitive: Right(CoreBanking)
scala name      : Left(DecodingFailure at : 'CoreBanking' is not a member of enum lesson3.Source$@64adb735)
encode          : "CARD_SYSTEM"

Case is ignored, but the name still has to be right: "CoreBanking" still fails, because ignoring case doesn't turn camel case into snake case. Encoding is unchanged and still writes the canonical "CARD_SYSTEM". That's the combination I'd want at a system boundary: lenient about what comes in, exact about what goes out.

I'd keep this as the exception, though. Strict decoding is what makes a typo in another team's producer show up as a failure on day one instead of as wrong data later.

What I'm taking to work

  • Choosing the entryName (or value) is choosing the JSON contract. Renaming a case object is safe. Changing its entryName is a breaking API change.
  • CirceEnum / IntCirceEnum on the companion object is the default. Write the codecs by hand only when you need different behaviour.
  • Decoding fails closed, with a path to the bad field. Keep it that way unless you have a specific upstream to accommodate.

Run it yourself

git clone https://github.com/zakariahere/learning-enumeratum
cd learning-enumeratum
sbt "runMain lesson3.CirceDemo" "runMain lesson3.LenientDemo"

Next up

Storing enums in a database: mapping entryName and value to columns, and what happens when the table contains a value the code doesn't know.

Keep reading