Nullable and Default
One question that frequently arises when dealing with JSON, and to a limited extend CBOR as well, is how to deal with null values.
null values differ from missing members (see also Map-Based Codecs) in that the value for an element is indeed present, but is null.
borer handles this case in a properly typed fashion: If your data model allows for certain members to have a null encoding that you would like to treat specially the member’s type should be wrapped with Nullable, e.g. Nullable[String].
In combination with the simple type class Default[T], which provides the capability to supply default values for a type T, the pre-defined Encoder and Decoder for Nullable[T] will be able to translate null values to the respective default value and back.
Example:
sourceimport io.bullet.borer.{Json, Codec, Nullable}
import io.bullet.borer.derivation.MapBasedCodecs.*
case class Dog(age: Int, name: Nullable[String]) derives Codec
Json
.decode("""{ "age": 4, "name": null }""" getBytes "UTF8")
.to[Dog]
.value ==>
Dog(age = 4, name = "") // the `Default[String]` provides an empty String
NullOptions
Sometimes it’s convenient to map “nullable” fields to Options, i.e. null to None and non-null to Some. This can easily be done with this import:
import io.bullet.borer.NullOptions.given
Here is how NullOptions are implemented:
source/**
* In order to enable an alternative [[Option]] codec, which
* encodes `None` to `null` and `Some` to an unwrapped value
* you can import the members of this object with
*
* {{{
* import io.bullet.borer.NullOptions.given
* }}}
*/
object NullOptions:
given encoder[T: Encoder]: Encoder[Option[T]] =
Encoder {
case (w, Some(x)) => w.write(x)
case (w, None) => w.writeNull()
}
given decoder[T: Decoder]: Decoder[Option[T]] =
Decoder { r =>
if (r.tryReadNull()) None
else Some(r.read[T]())
}At first glance it might seem that NullOptions could actually be the default way to encode a value of type Option[T] since null appears as the “natural” construct in JSON / CBOR to represent “no value”.
However, NullOptions have a serious drawback which makes them unsuitable as the generally preferred representation strategie for Option[T]:
They are unsound as in “they don’t compose”.
Consider the type Option[Option[T]]. With NullOptions its value Some(None) would serialize to null, which would then deserialize to None rather than Some(None), which is unsound as it violates the basic “roundtrip requirement” deserialize(serialize(x)) == x.