Home
May 26, 2026

One ReadWriter to Rule Them All

scalaupicklejsonscalajs

A few small things I’ve learned about uPickle while using it as the single JSON layer of my shared DTO setup. Nothing revolutionary, but each one cost me a search, so into the journal they go.

Put the Codec in the Companion

In Scala 3, derives does this for you and it’s the whole reason to use it:

1case class Entry(id: String, title: String) derives ReadWriter

The ReadWriter lands in Entry’s companion object, so it’s found by implicit lookup everywhere, with no imports and, this is the part I care about, defined exactly once for both the JVM and the Scala.js sides of the shared module. If you write given instances in some JsonCodecs object instead, you’re one missing import away from two subtly different codecs.

Defaults Are Your Migration Strategy

uPickle omits fields that equal their default when writing, and tolerates their absence when reading:

1case class Entry(
2    id: String,
3    title: String,
4    pinned: Boolean = false   // added in v2, old JSON still parses
5) derives ReadWriter

This is the entire backwards-compatibility story for adding a field, and it’s free. It also means the wire payloads stay small, a list of entries where nothing is pinned never mentions pinned at all.

Sealed Traits Get a $type Tag

For ADTs, uPickle writes a discriminator automatically:

1enum ApiError derives ReadWriter:
2  case NotFound(id: String)
3  case Invalid(field: String, message: String)
1{"$type": "NotFound", "id": "abc"}

You can rename the tag key or values with annotations, but honestly the default is fine when both ends of the wire are your own Scala code, nobody else ever sees it.

The Long Trap

JS numbers only have 53 safe integer bits, so uPickle serializes Long as a string by default. Correct, but it surprised me in the browser dev tools:

1{"publishedAt": "1748291580000"}

For timestamps I switched the DTOs to plain epoch-seconds Int fields, ugly-ish, but uniform across platforms, and this blog doesn’t need dates past 2038… I hope.

The meta-lesson, again: when the frontend and backend share codecs, every one of these decisions gets made once. The old version of me maintained a serializer config in two languages and hoped.