An Introduction to Chiron

Read Time: 17 minutes

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
2
3
dotnet new console -lang f# --name Introduction
cd Introduction
dotnet add package Chiron --version 6.3.1

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type Player = {
Name :string;
Score :int;
Notes :string list;
IntPairs :(int * int) list;
} with

static member ToJson (p :Player) = json {
do! Json.write "name" p.Name
do! Json.write "score" p.Score
do! Json.write "notes" p.Notes
do! Json.write "int_pairs" p.IntPairs
}

static member FromJson (_ :Player) = json {
let! name = Json.read "name"
let! score = Json.read "score"
let! notes = Json.read "notes"
let! intPairs = Json.read "int_pairs"

return {
Player.Name = name;
Score = score;
Notes = notes;
IntPairs = intPairs;
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[<EntryPoint>]
let main argv =

let player1 = {
Player.Name = "Jane";
Score = 100;
Notes = [
"This is a note";
"This is another note" ];
IntPairs = [
(1,3);
(13, 87) ];
}

printfn "Player1: %A" player1

let player1Json = (Json.serialize >> Json.format) player1
printfn "Json (compact): \n%s" player1Json

let player1JsonPretty = (Json.serialize >> Json.formatWith JsonFormattingOptions.Pretty) player1
printfn "Json (pretty): \n%s" player1JsonPretty

let player1' :Player = (Json.parse >> Json.deserialize) player1Json
printfn "Player1': %A" player1'

0

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Player1 object
Player1: {Name = "Jane";
Score = 100;
Notes = ["This is a note"; "This is another note"];
IntPairs = [(1, 3); (13, 87)];}

# Player1 as json
Json (compact):
{"int_pairs":[[1,3],[13,87]],"name":"Jane","notes":["This is a note","This is another note"],"score":100}

# Player1 as prettified json
Json (pretty):
{
"int_pairs": [
[
1,
3
],
[
13,
87
]
],
"name": "Jane",
"notes": [
"This is a note",
"This is another note"
],
"score": 100
}

# Player1' object (deserialized from json string)
Player1': {Name = "Jane";
Score = 100;
Notes = ["This is a note"; "This is another note"];
IntPairs = [(1, 3); (13, 87)];}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// Point
type Point = {
X :int;
Y :int
} with

static member ToJson (p :Point) = json {
do! Json.write "x" p.X
do! Json.write "y" p.Y
}

static member FromJson (_ :Point) = json {
let! x = Json.read "x"
let! y = Json.read "y"

return { Point.X = x; Point.Y = y }
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Add a Coordinates field that is a list of Points to the definition of the Player type
type Player = { ...
Coordinates :Point list;
}

// Add the new field to the Player's ToJson member function
static member ToJson (p :Player) = json { ...
do! Json.write "coordinates" p.Coordinates
}

// Add the new field to the Player's FromJson member function
static member FromJson (_ :Player) = json { ...
let! coordinates = Json.read "coordinates"
...
return { ...
Coordinates = coordinates
}
}

// When creating the Player object, we need to populate the Coordinates field
player1 = { ...
Coordinates = [
{ X = 30; Y = 40 };
{ X = 30; Y = 41 } ]
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// Direction
type Direction = N | S | E | W with
static member ToJson (d :Direction) =
match d with
| N -> ToJsonDefaults.ToJson "N"
| E -> ToJsonDefaults.ToJson "E"
| S -> ToJsonDefaults.ToJson "S"
| W -> ToJsonDefaults.ToJson "W"

static member FromJson (_ :Direction) = fun json ->
match json with
| String "N" -> Value N, json
| String "E" -> Value E, json
| String "S" -> Value S, json
| String "W" -> Value W, json
| _ -> failwith (sprintf "Invalid Direction '%A'" json)
// Alternative: Silently fail to a default
// | _ -> Value N, json

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Add a CurrentDirection field to the definition of the Player type
type Player = { ...
CurrentDirection :Direction
}

// Add the new field to the Player's ToJson member function
static member ToJson (p :Player) = json { ...
do! Json.write "current_direction" p.CurrentDirection
}

// Add the new field to the Player's FromJson member function
static member FromJson (_ :Player) = json { ...
let! currentDirection = Json.read "current_direction"
...
return { ...
CurrentDirection = currentDirection
}
}

// When creating the Player object, we need to populate the CurrentDirection field
player1 = { ...
CurrentDirection = Direction.N
}

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
2
3
4
5
6
/// Level 
type Level =
| Zero = 0
| One = 1
| Two = 2
| Three = 3

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Add a Level field to the definition of the Player type
type Player = { ...
Level: Level
}

// Add the new field to the Player's ToJson member function
static member ToJson (p :Player) = json { ...
do! Json.write "level" (int p.Level)
}

// Add the new field to the Player's FromJson member function
static member FromJson (_ :Player) = json { ...
let! level = Json.read "level"
...
return { ...
Level = enum<Level>(level)
}
}

// When creating the Player object, we need to populate the CurrentDirection field
player1 = { ...
Level = Level.Two;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/// Role 
type Role =
| Scout of string
| Assault of string
| Defense of string
| Swarm of int
with

static member ToJson (r :Role) =
match r with
| Scout(x) -> json {
do! Json.write "type" "scout"
do! Json.write "value" x }
| Assault(x) -> json {
do! Json.write "type" "assault"
do! Json.write "value" x }
| Defense(x) -> json {
do! Json.write "type" "defense"
do! Json.write "value" x }
| Swarm(x) -> json {
do! Json.write "type" "swarm"
do! Json.write "value" x }

static member FromJson (_ :Role) = json {
let! role = Json.read "type"

match role with
| "scout" -> let! value = Json.read "value"
return Scout(value)
| "assault" -> let! value = Json.read "value"
return Assault(value)
| "defense" -> let! value = Json.read "value"
return Defense(value)
| "swarm" -> let! value = Json.read "value"
return Swarm(value)
| _ -> failwith (sprintf "Invalid Role '%A'" role)
return Swarm(0)
}

We’re back to the familar process of modifying our 3 touch points: definition, ToJson, and FromJson.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Add a Role field to the definition of the Player type
type Player = { ...
Role: Role
}

// Add the new field to the Player's ToJson member function
static member ToJson (p :Player) = json { ...
do! Json.write "role" p.Role
}

// Add the new field to the Player's FromJson member function
static member FromJson (_ :Player) = json { ...
let! role = Json.read "role"
...
return { ...
Role = role
}
}

// When creating the Player object, we need to populate the Role field
player1 = { ...
Role = Scout("ax-101");
}

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
2
3
player1 = { ...
Role = Swarm(200);
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
type Player = {
Name :string;
Score :int;
Notes :string list;
IntPairs :(int * int) list;
Coordinates :Point list;
CurrentDirection :Direction;
Level :Level;
Role :Role
} with

static member ToJson (p :Player) = json {
do! Json.write "name" p.Name
do! Json.write "score" p.Score
do! Json.write "notes" p.Notes
do! Json.write "int_pairs" p.IntPairs
do! Json.write "coordinates" p.Coordinates
do! Json.write "current_direction" p.CurrentDirection
do! Json.write "level" (int p.Level)
do! Json.write "role" p.Role
}

static member FromJson (_ :Player) = json {
let! name = Json.read "name"
let! score = Json.read "score"
let! notes = Json.read "notes"
let! intPairs = Json.read "int_pairs"
let! coordinates = Json.read "coordinates"
let! currentDirection = Json.read "current_direction"
let! level = Json.read "level"
let! role = Json.read "role"

return {
Player.Name = name;
Score = score;
Notes = notes;
IntPairs = intPairs;
Coordinates = coordinates;
CurrentDirection = currentDirection;
Level = enum<Level>(level);
Role = role
}
}

This has been a light introduction into using Chiron. Hopefully you have found it useful. Until next time.