I covered the difficulties involved in deserializing JSON in an earlier blog post. I teased Thoth.Json, a great tool for JSON serialization there. In this post, I will demonstrate Thoth.Json, and show how it makes deserialization a breeze!
Why Thoth.Json?
Converting JSON into domain types can be quite challenging. Its complexity is nicely illustrated in a blog post by Einarwh. The gist is that you can't fully automate JSON serialization without it becoming a source of bugs: Change a field name in your domain, and your JSON serialization fails.
The solution suggested by einarwh is to create a separate JSON object model, an approach that we explored in my earlier blog post. Thoth.Json takes a slightly different approach: it makes deserializing more explicit by making the user write decoders for the object types. This can be a great approach for data that is roughly structured in the right way, that you want to convert into a rich domain with reusable decoders.
Throughout this post, I will gradually build up a domain object, showing more features as we go along
Because Thoth.Json relies on platform-dependent JSON decoders, Thoth.Json has different packages for .NET and Fable.
Thoth.Json
is for Fable,Thoth.Json.Net
is for .NET.
Object deserialization
To start off, let's make a very simple model for a blog post:
type Post =
{ Title: string
Content: string
Published: DateTime option
Id: int }
We have 3 required fields, and an optional field. Let's create a decoder! By convention, Thoth.Json encoders and decoders are placed in a module with the type. Object decoders are built by writing a function that takes an instance of Decoder.IGetter
, that provides access to the JSON content, and returns the domain object.
An IGetter object has 2 properties:
Required
, for getting required fields; these fail if the field is not foundOptional
, for getting optional fields; these returnNone
if the field is not found
The IGetter functions take a field name, and a decoder that transforms the JSON value into the specified type. Thoth.Json comes with a range of decoders, but you can write any missing decoders yourself.
module Post =
let decode: Decoder<Post> =
Decode.object (fun get ->
{ Title = get.Required.Field "title" Decode.string
Content = get.Required.Field "content" Decode.string
Published = get.Optional.Field "published" Decode.datetimeUtc
Id = get.Required.Field "id" Decode.int })
Mapping values
In F# domain modelling, we often create single case Discriminated Union types to constrain where and how a value can be used. Let's make such a type for our post ID:
type PostId = PostId of int
type Post =
{ Title: string
Content: string
Published: DateTime option
Id: PostId }
We can now simply wrap our PostId in that type:
module Post =
let decode: Decoder<Post> =
Decode.object (fun get ->
{ Title = get.Required.Field "title" Decode.string
Content = get.Required.Field "content" Decode.string
Published = get.Optional.Field "published" Decode.datetimeUtc
Id = PostId(get.Required.Field "id" Decode.int) })
But it's not very elegant. Let's make our own decoder for post Id. Thoth.Json provides the Decode.map
function, allowing us to easily wrap the result of the int
decoder in a PostId
We're left with a custom decoder, that we can simply use in our exising Post
decoder.
module PostId =
let decoder: Decoder<PostId> = Decode.int |> Decode.map PostId
module Post =
let decode: Decoder<Post> =
Decode.object (fun get ->
{ Title = get.Required.Field "title" Decode.string
Content = get.Required.Field "content" Decode.string
Published = get.Optional.Field "published" Decode.datetimeUtc
Id = get.Required.Field "id" PostId.decoder })
Nested records
Now that we can write our own decoders and combine them, nested records are easy to decode; let's create a new Author
type, and add it to our post type:
type Author = { Name: string }
type Post =
{ Title: string
Content: string
Published: DateTime option
Id: PostId
Author: Author }
We start by writing an Author
decoder, similar to we did it for Post
, and use it within the Post decoder
module Author =
let decode: Decoder<Author> =
Decode.object (fun get -> { Name = get.Required.Field "name" Decode.string })
module Post =
let decode: Decoder<Post> =
Decode.object (fun get ->
{ Title = get.Required.Field "title" Decode.string
Content = get.Required.Field "content" Decode.string
Published = get.Optional.Field "published" Decode.datetimeUtc
Id = get.Required.Field "id" PostId.decoder
Author = get.Required.Field "author" Author.decode })
Decoding enums
So far, we've mainly been decoding simple values and records. Let's have a look at decoding a simple discriminated union without any associated data. For this, we'll use the Decode.andThen
function, that allows us to map the result of a decoder and validate it at the same time. Decode.succeed
for branches where the JSON value is valid, or Decode.fail
when the value is invalid.
To illustrate, let's extend our author with a Role
in the form of a Discriminated Union
type Role =
| Developer
| SalesPerson
| CEO
type Author = { Role: Role; Name: string }
Again, we write a custom decoder that combines a standard string decoder with our custom logic:
module Role =
let decoder: Decoder<Role> =
Decode.string
|> Decode.andThen (function
| "developer" -> Decode.succeed Developer
| "salesPerson" -> Decode.succeed SalesPerson
| "CEO" -> Decode.succeed CEO
| other -> Decode.fail $"Unknown role: {other}")
And once again, we can simply call it as any other decoder:
module Author =
let decode: Decoder<Author> =
Decode.object (fun get ->
{ Role = get.Required.Field "role" Role.decoder
Name = get.Required.Field "name" Decode.string })
Thoth.Json provides us with a very flexible set of tools to build JSON decoders. Using it makes the decoding process a lot more explicit as compared to automatic decoders like System.Text.Json
. In projects with simple models that roughly correlate with the associated JSON, you'll find that Thoth.Json can often be used to directly decode into the domain, allowing you to get rid of the DTO and decoder pattern that I described in my earlier blog post. To learn more about Thoth.Json, check out their documentation.