Scala - JSON Parsing (circe/play-json)
Add these dependencies to your `build.sbt`:
Key Insights
- Circe provides a purely functional, type-safe approach to JSON handling with automatic codec derivation, while Play JSON offers a more flexible, macro-based system integrated with the Play Framework
- Circe’s cursor-based API excels at navigating complex JSON structures, whereas Play JSON’s combinator pattern provides fine-grained control over serialization logic
- Both libraries handle common scenarios like missing fields and type mismatches differently—Circe uses Either for error handling while Play JSON relies on JsResult
Library Setup and Dependencies
Add these dependencies to your build.sbt:
// Circe
libraryDependencies ++= Seq(
"io.circe" %% "circe-core" % "0.14.6",
"io.circe" %% "circe-generic" % "0.14.6",
"io.circe" %% "circe-parser" % "0.14.6"
)
// Play JSON
libraryDependencies += "com.typesafe.play" %% "play-json" % "2.10.4"
Both libraries require Scala 2.13+ or Scala 3. Circe follows a modular design where you import only what you need, while Play JSON is more monolithic.
Basic Parsing and Serialization
Circe Approach
import io.circe._
import io.circe.parser._
import io.circe.syntax._
import io.circe.generic.auto._
case class User(id: Long, name: String, email: String)
// Parsing JSON string
val jsonString = """{"id": 1, "name": "Alice", "email": "alice@example.com"}"""
val parsed: Either[Error, User] = decode[User](jsonString)
parsed match {
case Right(user) => println(s"Parsed: $user")
case Left(error) => println(s"Failed: ${error.getMessage}")
}
// Serializing to JSON
val user = User(1, "Alice", "alice@example.com")
val json: String = user.asJson.noSpaces
println(json) // {"id":1,"name":"Alice","email":"alice@example.com"}
Play JSON Approach
import play.api.libs.json._
case class User(id: Long, name: String, email: String)
// Define implicit Format (reads + writes)
implicit val userFormat: Format[User] = Json.format[User]
// Parsing JSON string
val jsonString = """{"id": 1, "name": "Alice", "email": "alice@example.com"}"""
val parsed: JsResult[User] = Json.parse(jsonString).validate[User]
parsed match {
case JsSuccess(user, _) => println(s"Parsed: $user")
case JsError(errors) => println(s"Failed: $errors")
}
// Serializing to JSON
val user = User(1, "Alice", "alice@example.com")
val json: String = Json.stringify(Json.toJson(user))
println(json)
Handling Complex Nested Structures
Circe with Manual Decoders
import io.circe._
import io.circe.parser._
case class Address(street: String, city: String, zipCode: String)
case class Company(name: String, address: Address)
case class Employee(id: Long, name: String, company: Company, tags: List[String])
implicit val addressDecoder: Decoder[Address] = (c: HCursor) => {
for {
street <- c.downField("street").as[String]
city <- c.downField("city").as[String]
zipCode <- c.downField("zip_code").as[String]
} yield Address(street, city, zipCode)
}
implicit val companyDecoder: Decoder[Company] = (c: HCursor) => {
for {
name <- c.downField("name").as[String]
address <- c.downField("address").as[Address]
} yield Company(name, address)
}
implicit val employeeDecoder: Decoder[Employee] = (c: HCursor) => {
for {
id <- c.downField("id").as[Long]
name <- c.downField("name").as[String]
company <- c.downField("company").as[Company]
tags <- c.downField("tags").as[List[String]]
} yield Employee(id, name, company, tags)
}
val complexJson = """{
"id": 100,
"name": "Bob",
"company": {
"name": "TechCorp",
"address": {
"street": "123 Main St",
"city": "Boston",
"zip_code": "02101"
}
},
"tags": ["scala", "backend", "senior"]
}"""
val result = decode[Employee](complexJson)
Play JSON with Custom Reads/Writes
import play.api.libs.json._
import play.api.libs.functional.syntax._
case class Address(street: String, city: String, zipCode: String)
case class Company(name: String, address: Address)
case class Employee(id: Long, name: String, company: Company, tags: List[String])
implicit val addressReads: Reads[Address] = (
(__ \ "street").read[String] and
(__ \ "city").read[String] and
(__ \ "zip_code").read[String]
)(Address.apply _)
implicit val addressWrites: Writes[Address] = (
(__ \ "street").write[String] and
(__ \ "city").write[String] and
(__ \ "zip_code").write[String]
)(unlift(Address.unapply))
implicit val companyFormat: Format[Company] = Json.format[Company]
implicit val employeeFormat: Format[Employee] = Json.format[Employee]
val complexJson = """{
"id": 100,
"name": "Bob",
"company": {
"name": "TechCorp",
"address": {
"street": "123 Main St",
"city": "Boston",
"zip_code": "02101"
}
},
"tags": ["scala", "backend", "senior"]
}"""
val result = Json.parse(complexJson).validate[Employee]
Handling Optional Fields and Defaults
Circe with Optional Fields
import io.circe.generic.semiauto._
case class Config(
host: String,
port: Int,
timeout: Option[Int],
retries: Int = 3
)
implicit val configDecoder: Decoder[Config] = (c: HCursor) => {
for {
host <- c.downField("host").as[String]
port <- c.downField("port").as[Int]
timeout <- c.downField("timeout").as[Option[Int]]
retries <- c.downField("retries").as[Option[Int]]
} yield Config(host, port, timeout, retries.getOrElse(3))
}
val json1 = """{"host": "localhost", "port": 8080}"""
val json2 = """{"host": "localhost", "port": 8080, "timeout": 5000, "retries": 5}"""
decode[Config](json1) // Config("localhost", 8080, None, 3)
decode[Config](json2) // Config("localhost", 8080, Some(5000), 5)
Play JSON with Default Values
case class Config(
host: String,
port: Int,
timeout: Option[Int] = None,
retries: Int = 3
)
implicit val configReads: Reads[Config] = (
(__ \ "host").read[String] and
(__ \ "port").read[Int] and
(__ \ "timeout").readNullable[Int] and
(__ \ "retries").read[Int].orElse(Reads.pure(3))
)(Config.apply _)
val json1 = """{"host": "localhost", "port": 8080}"""
val json2 = """{"host": "localhost", "port": 8080, "timeout": 5000, "retries": 5}"""
Json.parse(json1).validate[Config]
Json.parse(json2).validate[Config]
Working with JSON Arrays and Transformations
Circe Array Processing
import io.circe.generic.auto._
case class Product(id: Long, name: String, price: Double)
val jsonArray = """[
{"id": 1, "name": "Laptop", "price": 999.99},
{"id": 2, "name": "Mouse", "price": 29.99},
{"id": 3, "name": "Keyboard", "price": 79.99}
]"""
val products: Either[Error, List[Product]] = decode[List[Product]](jsonArray)
// Transform prices
products.map { productList =>
productList.map(p => p.copy(price = p.price * 1.1))
}
// Filter and re-serialize
val expensiveProducts = products.map { list =>
list.filter(_.price > 50.0).asJson.noSpaces
}
Play JSON Array Manipulation
implicit val productFormat: Format[Product] = Json.format[Product]
val jsonArray = """[
{"id": 1, "name": "Laptop", "price": 999.99},
{"id": 2, "name": "Mouse", "price": 29.99},
{"id": 3, "name": "Keyboard", "price": 79.99}
]"""
val products: JsResult[List[Product]] = Json.parse(jsonArray).validate[List[Product]]
// Transform using JsArray directly
val jsArray = Json.parse(jsonArray).as[JsArray]
val transformed = JsArray(jsArray.value.map { item =>
item.as[JsObject] ++ Json.obj("discounted" -> true)
})
// Filter products over $50
products.map { list =>
Json.toJson(list.filter(_.price > 50.0))
}
Advanced Error Handling and Validation
Circe Custom Validation
import io.circe._
case class Email(value: String)
implicit val emailDecoder: Decoder[Email] = Decoder[String].emap { str =>
if (str.contains("@")) Right(Email(str))
else Left("Invalid email format")
}
case class UserProfile(username: String, email: Email, age: Int)
implicit val userProfileDecoder: Decoder[UserProfile] = (c: HCursor) => {
for {
username <- c.downField("username").as[String]
email <- c.downField("email").as[Email]
age <- c.downField("age").as[Int]
validated <- if (age >= 18) Right(age) else Left(DecodingFailure("Age must be 18+", c.history))
} yield UserProfile(username, email, validated)
}
val invalidJson = """{"username": "john", "email": "invalid", "age": 16}"""
decode[UserProfile](invalidJson) // Returns detailed error
Play JSON Custom Validation
case class Email(value: String)
case class UserProfile(username: String, email: Email, age: Int)
implicit val emailReads: Reads[Email] = Reads[String].filter(
JsonValidationError("Invalid email")
)(_.contains("@")).map(Email)
implicit val userProfileReads: Reads[UserProfile] = (
(__ \ "username").read[String] and
(__ \ "email").read[Email] and
(__ \ "age").read[Int].filter(JsonValidationError("Must be 18+"))(_ >= 18)
)(UserProfile.apply _)
val invalidJson = """{"username": "john", "email": "invalid", "age": 16}"""
Json.parse(invalidJson).validate[UserProfile] // Returns JsError with details
Performance Considerations
Circe’s automatic derivation using macros generates code at compile time, resulting in zero runtime reflection overhead. Play JSON’s macro-based approach also avoids reflection but generates slightly more verbose code.
For high-throughput applications parsing large JSON arrays, Circe’s streaming parser provides better memory efficiency:
import io.circe.jawn.CirceSupportParser
import org.typelevel.jawn.AsyncParser
val parser = CirceSupportParser.async[Json](AsyncParser.UnwrapArray)
// Feed chunks incrementally without loading entire JSON into memory
Play JSON lacks built-in streaming support but integrates seamlessly with Akka Streams for reactive JSON processing in Play Framework applications.