1. Introduction
JSON (JavaScript Object Notation) is a self-describing text-based format to represent structured data. Even though it is named after JavaScript, we can work with JSON objects in any programming language. Only its syntax resembles how we construct objects in JavaScript.
JSON is particularly useful for transmitting data over the network, as JSON objects are simple strings that any program can read and parse. Every modern programming language has several libraries to handle JSON and provide ways to turn structured data into JSON objects and vice versa.
In this article, we’ll examine one such library, ZIO Json. The library comes with out-of-the-box integration with the ZIO framework, we can use it in any Scala-based application.
We’ll examine the two main use cases: encoding and decoding. The former means turning structured data, such as a Scala case class or an Algebraic Data Type into a JSON object. The latter, on the other hand, involves parsing a string and turning the underlying JSON object into an instance of a given type in Scala.
2. Setup
To use zio-json in our application, we’ll have to add it to our project’s dependencies in build.sbt:
libraryDependencies += "dev.zio" %% "zio-json" % "0.6.2"
zio-json provides dedicated artifacts to simplify the interoperability with third-party libraries, such as Akka Http and Http4s. The complete list is in the official GitHub repository.
All the code snippets in the following sections assume the following import statement:
import zio.json.*
3. Encoding
As we saw above, encoding means turning a Scala object into its corresponding JSON representation.
In this section, we’ll see how to encode both a simple case class and an ADT into their JSON representation.
3.1. Default Encoding
Let’s see an example with a case class first. Let’s assume we want to build a type representing messages sent over the network, between two applications and encode them in JSON. First, we’ll start with a simple Start message, piggybacking a timeout field:
case class Start(timeout: Long)
Secondly, we’ll define an implicit value telling ZIO Json how to encode our Start case class:
object Start {
implicit val startEncoder: JsonEncoder[Start] = DeriveJsonEncoder.gen[Start]
}
We don’t have to explicitly tell ZIO Json how Start is defined, as we can rely on the automatic derivation performed by DeriveJsonEncoder.
Let’s now get to our first encoding example:
Start(100).toJson shouldBe """{"timeout":100}"""
After importing our implicit encoder, we use the toJson method in the zio.json package to turn an instance of Start into its JSON representation.
Determining encoders for a single case class is not very useful. Most of the time, we’d like to derive the structure of entire ADTs. ZIO Json can do this as well. Let’s make our Start class extend a new Command trait, and let’s add a new case object, Stop, and a new case class, Kill, to the party:
sealed trait Command
case class Start(timeout: Long) extends Command
case object Stop extends Command
case class Kill(reason: String, force: Boolean) extends Command
object Command {
implicit val encoder: JsonEncoder[Command] = DeriveJsonEncoder.gen[Command]
}
Nothing different than before. We can now test our brand-new ADT encoder:
(Start(100): Command).toJson shouldBe """{"Start":{"timeout":100}}"""
(Stop: Command).toJson shouldBe """{"Stop":{}}"""
(Kill(
reason = "Random reason",
force = false
): Command).toJson shouldBe """{"Kill":{"reason":"Random reason","force":false}}"""
There are some differences in the encoding now. First, we must explicitly state that Start, Stop, and Kill are of type Command. This way, ZIO Json will pick the Command encoder rather than the one we defined for Start, for example. Secondly, by default, ZIO Json encodes the subclasses as objects with a key.** The one for Stop, in particular, is empty.
3.2. Customizing the Encoding
To work around the behavior we saw above, we can use the JsonEncoder::contramap method to provide a more meaningful default case for Stop:
implicit val encoder: JsonEncoder[Stop.type] =
implicitly[JsonEncoder[String]].contramap(_.toString())
(Start(100): Command).toJson shouldBe """{"Start":{"timeout":100}}"""
Stop.toJson shouldBe """"Stop""""
(Kill(
reason = "Random reason",
force = false
): Command).toJson shouldBe """{"Kill":{"reason":"Random reason","force":false}}"""
In the snippet above, we defined a new Command encoder using the implicit String encoder as a starting point. This way, encoding Stop will not result in an empty object but, instead, in a plain String.
Alternatively, we can use the @jsonDiscriminator annotation to specify an extra field, type for instance, as a discriminator. However, this solution “pollutes” the Command definition with details about its serialization. Let’s see an example:
@jsonDiscriminator("type")
sealed trait Command2
case class Start2(timeout: Long) extends Command2
case object Stop2 extends Command2
case class Kill2(reason: String, force: Boolean) extends Command2
object Command2 {
implicit val encoder: JsonEncoder[Command2] = DeriveJsonEncoder.gen[Command2]
}
We’re now telling ZIO Json to add a field named type to the resulting JSON, using it to encode the corresponding case class:
(Start2(
100
): Command2).toJson shouldBe """{"type":"Start2","timeout":100}"""
(Stop2: Command2).toJson shouldBe """{"type":"Stop2"}"""
(Kill2(
reason = "Random reason",
force = false
): Command2).toJson shouldBe """{"type":"Kill2","reason":"Random reason","force":false}"""
ZIO Json gives us a lot of flexibility in customizing the encoding behavior. The best approach depends on our application’s use cases.
4. Decoding
As we saw above, decoding means turning a JSON string into a given Scala type instance, either a simple case class or an ADT.
Similarly to the encoding section, we’ll see how to decode a simple case class and an ADT.
4.1. Default Decoding
Again, let’s assume Start did not extend Command. In this case, as above, we can let ZIO Json derive the decoder on our behalf:
implicit val decoder: JsonDecoder[Start] = DeriveJsonDecoder.gen[Start]
Let’s see how to use this decoder:
"""{"timeout":789}""".fromJson[Start] shouldBe Right(Start(789))
With the fromJson method, we can tell ZIO Json to look for an implicit decoder and use it to turn a JSON object into the Start case class. fromJson[A] returns an instance of Either[String, A], with the right projection being the deserialized type and the left one being an error:
"""{"duration":789}""".fromJson[Start] shouldBe Left(".timeout(missing)")
In the example above, the JSON did not contain any timeout field. Therefore, the deserialization returned an error. However, ZIO Json will not return an error if there are extra fields:
"""{"timeout":789, "extra": "field"}""".fromJson[Start] shouldBe Right(
Start(789)
)
In other words, ZIO Json will deserialize a projection of the JSON object suitable for instantiating the target case class.
Let’s now take a look at the decoding of the Command ADT. Its generation is as above:
implicit val decoder: JsonDecoder[Command] = DeriveJsonDecoder.gen[Command]
We can use it to decode a JSON similarly to what we did above for Start:
"""{"Start":{"timeout":100}}""".fromJson[Command] shouldBe Right(
Start(100)
)
"""{"Stop":{}}""".fromJson[Command] shouldBe Right(Stop)
"""{"Kill":{"reason":"Random reason","force":false}}"""
.fromJson[Command] shouldBe Right(
Kill("Random reason", false)
)
4.2. Customizing the Decoding
As we saw above, we can customize the decoding process. For example, we can rely on JsonDecoder::map on the derived decoder for Stop, similarly to what we did with JsonEncooder::contramap:
implicit val decoder: JsonDecoder[Stop.type] =
implicitly[JsonDecoder[String]].map(_ => Stop)
"""{"Start":{"timeout":100}}""".fromJson[Command] shouldBe Right(
Start(100)
)
""""Stop"""".fromJson[Stop.type] shouldBe Right(Stop)
"""{"Kill":{"reason":"Random reason","force":false}}"""
.fromJson[Command] shouldBe Right(
Kill("Random reason", false)
)
Notice that we had to explicitly use Stop.type as a type parameter of fromJson. Otherwise, if we decoded it with fromJson[Command], the custom implicit decoder we defined with JsonDecoder::map would not get picked.
An alternative approach to customize the decoding is to use a discriminator, as we did above with the encoding:
@jsonDiscriminator("type")
sealed trait Command2
case class Start2(timeout: Long) extends Command2
case object Stop2 extends Command2
case class Kill2(reason: String, force: Boolean) extends Command2
object Command2 {
implicit val decoder: JsonDecoder[Command2] = DeriveJsonDecoder.gen[Command2]
}
In this case, the JSON object must include a type key indicating the corresponding Scala type:
"""{"type":"Start2","timeout":100}""".fromJson[Command2] shouldBe Right(
Start2(100)
)
"""{"type":"Stop2"}""".fromJson[Command2] shouldBe Right(Stop2)
"""{"type":"Kill2","reason":"Random reason","force":false}"""
.fromJson[Command2] shouldBe Right(
Kill2("Random reason", false)
)
If there’s no discriminator, the decoding will fail:
"""{"timeout":100}""".fromJson[Command2] shouldBe Left(
"(missing hint 'type')"
)
5. Conclusion
In this article, we provided a brief introduction to the ZIO Json library. We discussed the essentials of encoding and decoding and some hints about customizing the library’s behavior. Based on our application’s use cases, we can customize the serialization to / deserialization from JSON objects. The default behavior might not always be what we need.
As usual, you can find the code over on GitHub.