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]())
    }
Note

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.