Understanding JSON → Scala
Case classes and typeclass derivation.
Why Scala JSON is library-dependent, the three serious choices, and the implicit dance that derives encoders and decoders at compile time.
Case classes are the data model.
Scala case classes have been the answer to "what's a Java POJO?" since Scala 1.0. Immutable by default, with auto-generated equals / hashCode / toString / copy / pattern-matching support. A JSON DTO is just a case class: case class User(userId: Int, name: String, avatar: Option[String]). The serializer's job is to turn case classes into JSON and back.
Three serious library choices.
Circe, Play JSON, uPickle. Circe is the most popular in functional-leaning codebases — typeclass-based, compile-time derivation, integrates with cats. Play JSON ships with the Play framework and is widely used in non-functional projects — slightly more imperative API. uPickle is Li Haoyi's "minimal effort" alternative — fastest to adopt, smaller surface, slightly less rich. Pick one per project; mixing them inside a single codebase is rarely worth the cost.
Compile-time derivation.
The Scala way of getting an encoder/decoder is implicit derivation: write import io.circe.generic.auto._ and the compiler synthesizes them on demand for any case class in scope. No runtime reflection (unlike Jackson); no codegen step (unlike Dart). The compiler reads the case class's fields and emits the encoder logic at compile time. Slower compiles, faster runtime.
A worked example with Circe.
From { "user_id": 7, "name": "Q", "avatar": null }: import io.circe.generic.semiauto._
import io.circe.{Decoder, Encoder}
case class User(userId: Int, name: String, avatar: Option[String])
object User {
implicit val decoder: Decoder[User] = deriveDecoder
implicit val encoder: Encoder[User] = deriveEncoder
} The semiauto derivation is explicit and recommended over fully-auto for control over when the derivation fires (auto can be slow on large codebases). The implicits live in the companion object so they're discovered automatically wherever the case class is used.
Snake_case bridging
deriveConfiguredDecoder + Configuration.default.withSnakeCaseMemberNames
Configure once; transformer applies to every member.
deriveConfiguredDecoder[User]
= No per-field overrides needed
Option for nullable, sealed traits for unions.
Like Kotlin and Swift, Scala uses Option[T] for fields that can be missing or null. The decoder maps absent JSON keys to None, present non- null values to Some(value). For tagged unions, sealed traits give you the same shape as Kotlin's sealed classes: a closed set of subtypes, exhaustive pattern matching, type-driven discrimination. Circe's generic-extras module adds explicit discriminator support.
Scala 3 changes.
Scala 3 (2021) replaced the implicit-derivation machinery. The Mirror typeclass and derives keyword make derivation a first-class language feature: case class User(...) derives Encoder.AsObject, Decoder. Cleaner syntax; same compile-time semantics. Libraries are converging on Scala 3 support; most examples on the web still use Scala 2.13 patterns.
Effects libraries.
Scala's effect ecosystem (cats-effect, ZIO) wraps everything in monadic types. A server using Circe through http4s ends up with IO[Either[DecodingFailure, User]] rather than a plain User. The serialization step is the same; what changes is the calling convention. Codegen tools emit plain case classes and decoders; the effect wrapping happens at the call site, not the type definition.