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.

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 things to notice:
- The Scala name doesn't decode.
"WebBanking"fails because the wire name isWEB_BANKING. This is the same rule aswithNamein part 2: lookups matchentryNameonly. - The failure points at the field.
DecodingFailure at .channelandat .prioritytell you which field broke, which matters when a payload has twenty of them. - It's a value, not an exception.
decodereturns anEither, so a bad message from a queue becomes aLeftyou 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(orvalue) is choosing the JSON contract. Renaming a case object is safe. Changing itsentryNameis a breaking API change. CirceEnum/IntCirceEnumon 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.