scalaintermediate

Advanced JSON with Circe

Handle complex JSON with Circe: custom codecs, optics, cursor navigation, and error handling.

scala
import io.circe.*
import io.circe.syntax.*
import io.circe.parser.*
import io.circe.generic.auto.*

// Domain models
case class Address(street: String, city: String, zipCode: String)
case class Contact(email: String, phone: Option[String])
case class Person(name: String, age: Int, address: Address, contacts: List[Contact])

// Custom encoder/decoder
sealed trait Status
case object Active extends Status
case object Inactive extends Status
case class Suspended(reason: String) extends Status

given Encoder[Status] = Encoder.instance {
  case Active => Json.obj("type" -> "active".asJson)
  case Inactive => Json.obj("type" -> "inactive".asJson)
  case Suspended(r) => Json.obj("type" -> "suspended".asJson, "reason" -> r.asJson)
}

given Decoder[Status] = Decoder.instance { c =>
  c.downField("type").as[String].flatMap {
    case "active"    => Right(Active)
    case "inactive"  => Right(Inactive)
    case "suspended" => c.downField("reason").as[String].map(Suspended(_))
    case other       => Left(DecodingFailure(s"Unknown status: $other", c.history))
  }
}

// Custom field names
case class ApiResponse(totalCount: Int, pageSize: Int, items: List[String])

given Encoder[ApiResponse] = Encoder.instance { r =>
  Json.obj(
    "total_count" -> r.totalCount.asJson,
    "page_size" -> r.pageSize.asJson,
    "items" -> r.items.asJson
  )
}

given Decoder[ApiResponse] = Decoder.instance { c =>
  for
    total <- c.downField("total_count").as[Int]
    size  <- c.downField("page_size").as[Int]
    items <- c.downField("items").as[List[String]]
  yield ApiResponse(total, size, items)
}

@main def run(): Unit =
  // Encoding
  val person = Person(
    "Alice", 30,
    Address("123 Main St", "Springfield", "62701"),
    List(Contact("alice@test.com", Some("555-0123")),
         Contact("alice@work.com", None))
  )
  val json = person.asJson.spaces2
  println(s"Encoded:\n$json\n")

  // Decoding
  val decoded = decode[Person](json)
  println(s"Decoded: $decoded\n")

  // Cursor navigation
  val doc = parse("""
    {
      "users": [
        {"name": "Alice", "scores": [95, 87, 92]},
        {"name": "Bob", "scores": [88, 76, 91]}
      ],
      "metadata": {"count": 2, "version": "1.0"}
    }
  """).getOrElse(Json.Null)

  val cursor = doc.hcursor

  // Navigate to nested fields
  val firstUserName = cursor.downField("users").downN(0).downField("name").as[String]
  println(s"First user: $firstUserName")

  val allNames = cursor.downField("users").as[List[Json]]
    .map(_.flatMap(_.hcursor.downField("name").as[String].toOption))
  println(s"All names: $allNames")

  val version = cursor.downField("metadata").downField("version").as[String]
  println(s"Version: $version")

  // Modify JSON
  val modified = cursor
    .downField("metadata")
    .downField("count")
    .withFocus(_.mapNumber(n => Json.fromInt(n.toInt.getOrElse(0) + 1).asNumber.get))
    .top
  println(s"\nModified: ${modified.map(_.spaces2)}")

  // Custom status
  val statuses: List[Status] = List(Active, Inactive, Suspended("payment"))
  println(s"\nStatuses: ${statuses.asJson.spaces2}")

  // Error handling
  val badJson = """{ "name": 42 }"""
  decode[Person](badJson) match
    case Left(err) => println(s"\nError: ${err.getMessage}")
    case Right(p) => println(s"Parsed: $p")

Use Cases

  • API response parsing
  • Custom JSON serialization
  • JSON document transformation

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.