JSON Specifics

Since the CBOR data item primitives are a super-set of what is available in JSON, or, said differently, everything in JSON has a counterpart in CBOR, it’s not hard for borer to also support encoding to and decoding from JSON.

From borer’s point of view JSON is simply a slightly different binary format that only supports a subset of the CBOR data primitives. Like CBOR borer encodes and decodes JSON in a single pass, UTF-8 encoding and decoding to and from raw bytes on the fly.

All higher-level abstractions (i.e. Writer, Reader, Encoder, Decoder, Codec, etc.) are essentially agnostic to the (de)serialization target format. Nevertheless, the Writer and Reader types do have a target member, which enables custom logic to discriminate between the two variants, if required.
Since the underlying JSON renderer will throw exceptions on attempts to write data primitives that are not supported in JSON (like CBOR Tags, for example), this is sometimes necessary to efficiently support both formats.

For example, in order to write an empty array in the most efficient way to both CBOR and JSON using only the most low-level data items one would use this approach:

sourceimport io.bullet.borer.Writer

def writeEmptyArray(w: Writer): w.type =
  if (w.writingJson) w.writeArrayStart().writeBreak()
  else w.writeArrayHeader(0) // fixed-sized Arrays are not supported in JSON

But on a slightly higher level the Writer also gives you a way to write arrays (and maps) without having distinguish between CBOR and JSON yourself:

sourceimport io.bullet.borer.Writer

def writeAsUnaryArray(w: Writer, s: String): w.type =
  w.writeArrayOpen(1) // automatically chooses the most efficient
    .writeString(s)
    .writeArrayClose() // way to write an array of size one

As long as you rely on the somewhat higher-level parts of the Reader and Writer APIs or construct your (de)serialization purely logic from borer’s built-in or (and/or derived) Encoders and Decoders, your application will support both CBOR and JSON at the same time without any special casing whatsoever.

Base Encodings for Binary Data

One big drawback of JSON over CBOR is that JSON doesn’t provide any “first-class” representation of binary data. This is typically worked around by mapping binary data to a “Base Encoding”, e.g. the ones defined by RFC 4648.

In order to give your application an easy and flexible way to integrate with other systems borer supports a number of base encodings out of the box, specifically:

The default JSON encoding for Array[Byte] is base64.

In order to switch to a different base encoding in a particular scope define the a pair of implicits as in this example:

sourceimport io.bullet.borer.{Decoder, Encoder, Json}
import io.bullet.borer.encodings.BaseEncoding

val binaryData = hex"DEADBEEF"

// Json.encode(binaryData).toByteArray or
Json.encode(binaryData).toUtf8String ==> """"3q2+7w==""""

{
  // we need to explicitly define the encoder as well as the decoder
  // in order to "override" the defaults for Array[Byte] on either side
  given Encoder[Array[Byte]] = Encoder.forByteArray(BaseEncoding.zbase32)
  given Decoder[Array[Byte]] = Decoder.forByteArray(BaseEncoding.zbase32)

  Json.encode(binaryData).toUtf8String ==> """"54s575a""""
}

JSON Pretty Printing

Normally borer will output JSON in the most compact form, with no whitespace padding anywhere. However, you can enable “pretty” JSON rendering as shown here:

sourceimport io.bullet.borer.{Codec, Json}
import io.bullet.borer.derivation.MapBasedCodecs.*

sealed trait Animal derives Codec.All
case class Dog(age: Int, name: String)                      extends Animal
case class Cat(weight: Double, color: String, home: String) extends Animal
case class Mouse(colorRGB: List[Int], tail: Boolean)        extends Animal

val animals = List(
  Dog(5, "Rufus"),
  Cat(4.7, "red", "next door"),
  Mouse(colorRGB = List(73, 42, 64), tail = false),
)

Json
  .encode(animals)
  .withPrettyRendering(indent = 2) // enables pretty printing
  .toUtf8String ==>
"""|[
   |  {
   |    "Dog": {
   |      "age": 5,
   |      "name": "Rufus"
   |    }
   |  },
   |  {
   |    "Cat": {
   |      "weight": 4.7,
   |      "color": "red",
   |      "home": "next door"
   |    }
   |  },
   |  {
   |    "Mouse": {
   |      "colorRGB": [
   |        73,
   |        42,
   |        64
   |      ],
   |      "tail": false
   |    }
   |  }
   |]""".stripMargin

When (not) to use borer for JSON

Since borer treats JSON as a binary format and reads/writes from/to raw bytes it isn’t optimized for consuming or producing Strings as input or output. (Strings have to first be UTF-8 encoded in order to be readable by borer.)
So, if you need to frequently consume String input other JSON libraries will likely perform better. Also, if you need to manipulate the JSON structures in any way between (de)serializing from/to the wire and from/to your data model then borer will not help you and a DOM/AST-based JSON solution (like Circe) will likely be the better choice.

However, if all you need is an efficient way to convert raw network- or disk-bytes holding UTF-8 encoded JSON to and from your data model types, with no (or few) dependencies and maybe even with the option to target CBOR with no additional work required from your side, then borer should be a good choice.

Comparison with other Scala JSON Libraries

(Additions, corrections, improvement suggestions very welcome, especially to this section!)


Circe
AST/DOM- and type-class-based design
  • very mature
  • allows for extensive DOM-manipulation
  • many integration option already available
  • compatible with scala.js and [Scala Native]
  • depends on cats-core
  • type class derivation can be slow (at compile time)
  • borer decodes JSON more than twice as fast
  • no CBOR support

spray-json
AST/DOM- and type-class-based design
  • zero dependencies
  • pre-historic, clunky type class design
  • essentially unmaintained
  • no direct support for case classes w/ more than 22 members
  • no type class derivation for ADTs
  • borer decodes JSON more than 3 times as fast
  • not compatible with scala.js and [Scala Native]
  • no CBOR support
  • no updates since Dec 2021

µPickle
pull-style, type class-based design
  • no support for case classes w/ more than 64 members
  • no support for manual (no-macro) codec construction
  • borer decodes JSON more than 4 times as fast
  • no [Scala Native] support
  • no CBOR support

Jackson Scala
Java implementation with a Scala add-on
  • very mature
  • good performance
  • no type class-based API
  • several non-Scala dependencies
  • not compatible with scala.js and [Scala Native]
  • borer decodes JSON about 20% faster

Jsoniter Scala
pull-style, type class-based design
  • zero dependencies
  • very high performance (about 15% faster JSON decoding than borer)
  • support out of the box for all Scala collections and java.time.* classes
  • highly configurable
  • compatible with scala.js and [Scala Native]
  • very low-level core API
  • entirely macro-based high-level API
  • no pre-defined AST/DOM
  • no CBOR support