07 · Working with JSON¶
The Level 1 project stored tasks in a
hand-rolled pipe-delimited text format specifically to avoid needing JSON
before it was covered. This module fills that gap using
upickle, a small, dependency-light
JSON library for Scala. (Its sibling library circe
is the other library you'll see often in production Scala codebases — same
ideas, more type-class-heavy API — but upickle's derives syntax is the
gentler on-ramp.)
Adding upickle to a project¶
Deriving JSON for a case class¶
upickle can generate a ReadWriter for any case class automatically with
Scala 3's derives clause — no manual encoder/decoder boilerplate:
import upickle.default._
case class Address(city: String, zip: String) derives ReadWriter
case class Person(name: String, age: Int, address: Option[Address]) derives ReadWriter
val p = Person("Ada", 30, Some(Address("London", "E1")))
val json = write(p)
println(json)
// {"name":"Ada","age":30,"address":[{"city":"London","zip":"E1"}]}
val parsedBack = read[Person](json)
println(parsedBack) // Person(Ada,30,Some(Address(London,E1)))
println(parsedBack == p) // true -- case class equality makes round-tripping easy to verify
write serializes any type with a ReadWriter in scope to a JSON string;
read[T] parses a JSON string back into T, given the exact type
annotation (upickle needs read[Person], not just read, since the target
type can't be inferred from a String).
The trap: Option doesn't serialize the way you'd guess¶
It's natural to assume Some(x) becomes x or null and None becomes a
missing key or null. upickle actually represents Option[T] as a
JSON array of zero or one elements — [] for None, [value] for
Some(value):
val noAddress = Person("Bob", 25, None)
println(write(noAddress))
// {"name":"Bob","age":25,"address":[]}
val roundTripped = read[Person](write(noAddress))
println(roundTripped) // Person(Bob,25,None) -- round-trips correctly...
// ...but if you're consuming JSON from a NON-Scala API (a real HTTP
// endpoint, not one you serialized with upickle yourself), it almost
// certainly represents "no address" as either a missing "address" key or
// "address": null -- neither of which upickle's default Option encoding
// expects. Model fields coming from external APIs as plain (non-Option)
// types with sensible defaults, or write a custom ReadWriter, rather than
// assuming Option "just works" against arbitrary JSON.
This is the single most common surprise when picking up upickle: it's
perfectly consistent for JSON upickle produced itself, but it is not the
"idiomatic JSON" convention (missing key / null) most external APIs use
for optional fields.
Pretty-printing¶
println(write(p, indent = 2))
// {
// "name": "Ada",
// "age": 30,
// "address": [
// {
// "city": "London",
// "zip": "E1"
// }
// ]
// }
Parsing JSON you don't have a case class for¶
Sometimes you just need to pull a couple of fields out of a response
without modeling the whole shape. ujson.read parses into a generic,
dynamically-navigable ujson.Value tree:
val raw = """{"name":"Cleo","age":5,"tags":["cat","fluffy"]}"""
val parsed = ujson.read(raw)
println(parsed("name").str) // Cleo
println(parsed("age").num) // 5.0 -- JSON numbers are Double by default
println(parsed("tags").arr.map(_.str).mkString(","))// cat,fluffy
.str, .num, .arr, .obj, and .bool unwrap a ujson.Value to the
Scala type you expect — and throw if the value is actually a different
JSON type, which is the generic-tree equivalent of Option.get: fine for a
quick script, risky against JSON you don't fully control. For anything
long-lived, model it as a case class with derives ReadWriter instead so
the compiler checks the shape for you.
Handling malformed or unexpected JSON¶
Reading into a typed case class throws when the input doesn't match —
combine it with Try (from Module 4) to turn a
parse failure into an Option/Either instead of an uncaught exception:
import scala.util.Try
def parsePerson(json: String): Either[String, Person] =
Try(read[Person](json)).toEither.left.map(_ => s"invalid person JSON: $json")
println(parsePerson("""{"name":"Ada","age":30,"address":[]}"""))
// Right(Person(Ada,30,None))
println(parsePerson("""{"name":"NoAge"}"""))
// Left(invalid person JSON: {"name":"NoAge"})
Wrapping every external read[T] call this way is worth the small amount of
ceremony — it turns "a malformed response crashes the program" into "a
malformed response is a value the caller has to explicitly handle," exactly
the philosophy Module 4 built up.
How It Actually Works¶
"Deriving JSON for a case class" without writing any encoder by hand relies
on the same compile-time mechanism as implicit resolution:
libraries like upickle generate a ReadWriter[YourCaseClass] at compile
time using macros (or, in some libraries, Scala 3's inline/derivation
features) that inspect the case class's constructor parameter list —
available because a case class's field names and types are part of its
public, compiler-visible signature — and mechanically produce code that
walks each field, converting it via that field's own derived
ReadWriter, recursively. Nothing is discovered via reflection at
runtime; the encoding/decoding logic for Employee(name: String, salary:
Double) is generated once, as ordinary methods, when the project
compiles — which is also why forgetting to import the right implicit
gives a compile error ("no ReadWriter found") rather than a runtime
surprise.
The Option serialization "trap" comes directly from this mechanism:
there's no single universal JSON representation of "a missing field" vs.
"a field present with value null" vs. "None," so each library's derived
ReadWriter[Option[A]] makes its own choice (upickle typically omits the
key entirely for None, or represents it depending on version/config) —
the behavior is a property of the specific derived instance you're using,
not something intrinsic to Option itself.
Parsing JSON you don't have a case class for works because underneath the
case-class layer, every JSON library has an untyped AST type (e.g.
upickle's ujson.Value, a sealed hierarchy of Str, Num, Obj, Arr,
Bool, Null) — the exact same sealed-trait-plus-case-classes pattern
from Level 1 and Module
8, just modeling "any JSON value"
instead of your domain type. read[YourCaseClass](json) is really "parse
to this AST, then run the derived decoder against the AST" — two separate
compiler-generated/generic stages, which is why parsing to ujson.Value
and then manually navigating it (.obj("field").str) is always available
as a fallback when no case class fits the shape.
Cheat sheet¶
| Task | Code |
|---|---|
| Derive JSON support for a case class | case class Foo(...) derives ReadWriter |
| Serialize to a JSON string | write(value) |
| Pretty-print | write(value, indent = 2) |
| Parse into a known type | read[Foo](jsonString) |
| Parse into a generic tree | ujson.read(jsonString) |
| Navigate a generic tree | .obj, .arr, .str, .num, .bool, parsed("key") |
| Handle parse failure as data | Try(read[Foo](s)).toEither |
Exercise¶
Define case class Book(title: String, author: String, year: Int, tags: List[String]) derives ReadWriter.
Create a List[Book] with at least three entries, serialize the whole list
with write (hint: write(books) works directly on a List[Book] since
upickle can derive collection support once the element type has a
ReadWriter), then parse it back with read[List[Book]] and confirm it
equals the original list. Then take a hand-written JSON string missing the
year field and confirm read[Book] throws — wrap that call in a Try
and print a friendly error message instead of letting the exception
propagate.