Today’s post is a introduction to performing json serialization tasks using F# and Chiron.
There are various ways to perform serialization in F#, each has their own set of advantages and disadvantages. In particular, Chiron provides nice control over more complex types. Within Chiron there are multiple approaches. The examples provided are not exhaustive, but are meant to be a good starting point for how various types can be serialized and deserialized. The code will be based on a player object for a theoretical game. Using that as a premise, there are two major things we’ll look at: records and discriminated unions. Both will have their own unique variations.
Setup
First, add the package to the project.
1 | dotnet new console -lang f# --name Introduction |
Second, import the Chiron
namespace.
1 | open Chiron |
Record Types
Record are perhaps the most common type to serialize. They are also straightforward, once you understand the mechanisms at work. This first example uses just primative types that can be handled with no additional code required. Chiron expects ToJson
and FromJson
methods when serializing and deserializing (respectively). Both use a json {...}
computation expression. Serialization is accomplished with a series of do! Json.write <attribute name> <attribute value>
statements. This allows us to define what we want to be serialized. For Deserialization there are two steps. First, let! <var> = Json.read <attribute name>
extracts the values. Once we have the values, we need to construct the record and return
it.
1 | type Player = { |
Now that the supporting code is in place, let’s look at how to use it. Conversion in both ways basically requires two steps. To serialize, the record is serialized, then formatted (Json.serialize >> Json.format)
. This is also where we have addition options. The default json format is compact, which is typically want we want when passing data around. But if we want a nicer view, we can (Json.serialize >> Json.formatWith JsonFormattingOptions.Pretty)
to pretty print. The other side of the equation is deserialization. Here we parse, then deserialize (Json.parse >> Json.deserialize)
. The additional key here is to define the type we want to deserialize into. At a basic level, that is all there is to it. Everything else we’ll look at will be incremental expansions on these concepts.
1 |
|
Now, let’s take a look at the results. For the most part they are exactly as expected, which is good. The one caveat is the list tuples. Since json doesn’t have a concept of tuples, they are serialized into an array. This is fine, it’s more about knowing how the default serialization works. As with other things in Chiron, this could be modified by writing our own serialization code into a different format.
1 | # Player1 object |
The remaining examples will be an expansion of this one. It will allow us to focus on the new stuff without getting lost in a bulk of code. For completeness, I’ll provide the final version of Player
at the end of the post so it can all be seen together.
Records within Records
Next, records within records. To do this we’ll need to create another record type, Point
. Beyond the base type definition, the ToJson
and FromJson
functions need to be implemented, in a similar fashion as above.
1 | /// Point |
Adding the new field into the Player
record is simple. The beauty here is that as long as you define the appropriate methods in the Point
class, as we did above, Chiron handles the serialization/deserialization with little effort. We just have to remember there are 3 touch points: definition, ToJson, and FromJson.
1 | // Add a Coordinates field that is a list of Points to the definition of the Player type |
As we can see below, the Point list is now part of the player.
1 | {"coordinates":[{"x":30,"y":40},{"x":30,"y":41}],"int_pairs":[[1,3],[13,87]],"name":"Jane","notes":["This is a note","This is another note"],"score":100} |
Discriminated Unions - Part 1 (Simple)
Discriminated Unions manifest themselves in a couple different forms when serializing. As a result, we’ll look at these from a couple different angles. They sometimes require slightly more of a decision over records types. When serializing primative types we just take the defaults, which is great. With discriminated unions we need to decide how we want our serialization to look. In this case, we’ll look to add a “current direction” to the player, leveraging a Direction
type of North, South, East, or West. For this we’ll just encode the value as a string; it is the simpliest and most straightforward way. Of special note regarding the ToJson
and FromJson
functions, we don’t use the json {...}
computation expression. ToJson
encodes the string value as a Json
type. FromJson
returns a function that converts the string representation to a value.
Something that should be addressed, how to handle invalid values. For this example we’ll fail the parsing with an “Invalid Direction” error. As an alternative, that might make sense in some cases, it could just be encoded to a default value.
1 | /// Direction |
As we did before, we need to add the CurrentDirection
at 3 points: definition, ToJson, and FromJson. As we saw in the previous example, with the functions setup on our type, Chiron handles the rest.
1 | // Add a CurrentDirection field to the definition of the Player type |
As we can see below, the current direction is now part of the player.
1 | {"coordinates":[{"x":30,"y":40},{"x":30,"y":41}],"current_direction":"N","int_pairs":[[1,3],[13,87]],"name":"Jane","notes":["This is a note","This is another note"],"sco |
Discriminated Unions - Part 2 (Enums)
Discriminated Unions can also be used like enums. This requires a slightly different approach. Primarily, enums cannot have member functions, so the methods we use before won’t work. We’ll need a little more logic in the player part of the serialization/deserialization functions. For this we’ll define a player’s level. This is a bit contrived, since using a straight number for levels makes more sense, but this example will at least get the idea across.
1 | /// Level |
One thing that is the same is where we need to add Level
, the Player
: definition, ToJson, and FromJson. This is where we need to provide a bit more information regarding how we want to serialize the value. I believe the most straight forward way is to convert to the underlying int. In the ToJson
we need to cast as int. For FromJson
we need to cast from int to the Level
type.
1 | // Add a Level field to the definition of the Player type |
As we can see below, their level is now part of the player. And our method of serialization to int works as expected.
1 | {"coordinates":[{"x":30,"y":40},{"x":30,"y":41}],"current_direction":"N","int_pairs":[[1,3],[13,87]],"level":2,"name":"Jane","notes":["This is a note","This is another note"],"score":100} |
Discriminated Unions - Part 3 (Complex)
Discriminated unions offer more complex ways to represent their data. This means we have to make a decision about how we want to represent that data. This is one place where Chiron shines, it provides the power to represent complex types as we see fit. For this example, we’ll look at a player Role
that represents a more complex type. An object that has a type
and value
attribute feels like a simple way to serialize. There are certainly other ways this could be represented, and the attributes don’t neccessarily have to match for the varying types.
ToJson
uses a json {...}
computation expression. Since there is a mixture of string and int values withing the discriminated union, we need to put them within the match
. This creates an object representation. The FromJson
function first extracts the type
attribute from the Json object, then returns the appropriate Role
with its respective value. Since they all use value
, they look similar, but that attribute, or potentially list of attributes could vary depending on role.
1 | /// Role |
We’re back to the familar process of modifying our 3 touch points: definition, ToJson, and FromJson.
1 | // Add a Role field to the definition of the Player type |
As we can see below, the role is now serialized as an object.
1 | {"coordinates":[{"x":30,"y":40},{"x":30,"y":41}],"current_direction":"N","int_pairs":[[1,3],[13,87]],"level":2,"name":"Jane","notes":["This is a note","This is another note"],"role":{"type":"scout","value":"ax-101"},"score":100} |
Alternatively, if the Role
is Swarm
, the object is serialized as appropriate. This exactly what we want, a string value when it’s a string, and int value when it’s an int.
1 | player1 = { ... |
Result:
1 | {"coordinates":[{"x":30,"y":40},{"x":30,"y":41}],"current_direction":"N","int_pairs":[[1,3],[13,87]],"level":2,"name":"Jane","notes":["This is a note","This is another note"],"role":{"type":"swarm","value":200},"score":100} |
As promised, here is the complete definition of Player
, with all its attributes.
1 | type Player = { |
This has been a light introduction into using Chiron. Hopefully you have found it useful. Until next time.