Functional JSON
I’m using the wonderful Fsharp.Data library to parse the JSON for me and handle all the edge cases, such as seen in IETF RFC 7159. Fsharp.Data JSON parse spits out results into this structure:
type JsonValue =
| String of string
| Number of decimal
| Float of float
| Record of properties:(string * JsonValue)[]
| Array of elements:JsonValue[]
| Boolean of bool
| Null
This is a nice F# Discriminated Union that effectively models JSON as a tree. And I’m going to use this to parse the data from my game Morgemil into appropriate objects.
One of my objects represents a weapon in my game, and is modeled as this. All fine and dandy.
[<RequireQualifiedAccess>]
type WeaponRangeType =
| Melee = 0
| Ranged = 1
type Weapon =
{ ///Type of this weapon
RangeType: WeaponRangeType
///Base Range
BaseRange: int
///The number of hands required to wield this weapon
HandCount: int
///The weight of this item. Used in stamina
Weight: decimal
}
I would construct this from a JsonValue by accessing properties directly.
let LoadWeapon(values: JsonValue)
{ Weapon.BaseRange = values?baserange.AsInteger()
RangeType = values?rangetype |> CastEnum<WeaponRangeType>
HandCount = values?handcount.AsInteger()
Weight = values?weight.AsDecimal()
}
The above function has a lot of appeal in that it’s simple, succint, and works so long as the JsonValue has the required data.
But what if the required data is not there? Well, it would throw an exception. This isn’t inherently bad, but it sure got confusing because the Weapon Data is actually nested inside another object Items - Item - SubItem - Weapon
It’s not a good idea to nest multiple layers of functions that all might throw an exception when a JsonValue is unexpectedly different.
For something at the scope of my use case, I could get away with it because I could track down exceptions fairly easily. But what if this software were an enterprise POS (Point Of Sale) system? Any error in the code would be days of a programmer’s time spent to track the error down. And then the programmer would despair of refactoring the large amount of code and he/she would throw on a code band-aid until the next inevitable error. It would be well worth designing this code to handle errors more gracefully.
Actually, it would be worth my time to design this game to handle errors more gracefully. Yes, it’s a small project, but even a giant POS system probably started out small. I never know how large my code base will grow, and it’s worth it “to do it right” at the start.
The Concept
Understanding the expected structure of a JsonValue is either Successful or Unsuccessful, and this fits right into a standard F# structure “Result”.
type Result<'T,'TError> =
| Ok of ResultValue: 'T
| Error of ErrorValue: 'TError
This fits pretty well, but let’s customize it a little bit.
type JsonError =
| MissingProperty of PropertyName: string * Record: JsonValue
| InconsistentArray of WrongValues: JsonError []
| UnexpectedType of ExpectedType: string * JsonValue
| PropertyErrors of WrongValues: JsonError[]
type JsonResult<'a> = Result<'a,JsonError>
FSharp.Data has already done the hard work of parsing JsonValue string’s and such, let’s leverage those functions. For example AsInteger, which takes an IFormatProvider (culture specific, which is irrelevant for us) and returns a function to convert a JsonValue into an int option. If the JsonValue may be converted into an int, Some value is returned, else None is returned.
//System.IFormatProvider -> (JsonValue -> int option)
let AsInteger = FSharp.Data.Runtime.JsonConversions.AsInteger
That’s pretty good, but I want to treat inability to convert JsonValue into an Int, as an error. This function will do that for me.
let OptionToResult<'a> (name: string) (jsonValue: JsonValue) (value: 'a option): JsonResult<'a> =
match value with
| Some x -> Ok x
| None -> Error (JsonError.UnexpectedType(name, jsonValue))
With that, I can chain the JsonConversion.AsInteger into OptionToResult to create a function which returns a JsonResult from parsing a JsonValue.
let private culture = System.Globalization.CultureInfo.InvariantCulture
//JsonValue -> JsonResult<int>
let JsonAsInteger jsonValue = FSharp.Data.Runtime.JsonConversions.AsInteger culture jsonValue |> OptionToResult "int" jsonValue
To give some examples of other variants of this:
let JsonAsDateTime jsonValue = FSharp.Data.Runtime.JsonConversions.AsDateTime culture jsonValue |> OptionToResult "datetime" jsonValue
let JsonAsGuid jsonValue = FSharp.Data.Runtime.JsonConversions.AsGuid jsonValue |> OptionToResult "guid" jsonValue
let JsonAsEnum<'t when 't : (new: unit -> 't) and 't : struct and 't :> System.ValueType > jsonValue =
jsonValue
|> JsonAsString
|> Result.bind(
System.Enum.TryParse<'t>
>> function
| true, x -> Ok x
| false, _ -> Error (JsonError.UnexpectedType(typeof<'t>.Name, jsonValue)))
Tada! I can create JsonResult from JsonValue.
let iAmString = JsonValue.String "I am a string"
let jsonResult = JsonAsString iAmString
printfn "%A" jsonResult
//Ok "I am a string"
Complex Objects.
What I’ve done so far, is great for small things like strings and numbers. I haven’t yet addressed the weapon type given above. Here’s how that code to get a weapon looks now
json value {
let! baseRange = "baserange",JsonAsInteger
let! rangeType = "rangetype",JsonAsEnum<WeaponRangeType>
let! handCount = "handcount",JsonAsInteger
let! weight = "weight",JsonAsDecimal
return {
Weapon.BaseRange = baseRange
RangeType = rangeType
HandCount = handCount
Weight = weight
}
The concept for code that looks like this came from the F# Chiron library which uses a custom JSON parser to do everything I’m doing above (but I wanted to use FSharp.Data).
“json” is a F# Computation Expression which is syntactic sugar to do some extra stuff. I won’t get into the implementation, that’s a long subject which is explained here. The complete code for my computation expression is here.
type JsonBuilder<'t>(value: JsonValue) =
member this.Bind(m: _ option, f): _ option =
f m
member this.Bind<'u>(m: Result<'u,JsonError>, f): Result<'t,JsonError> =
match m with
| Ok x -> x |> f
| Error err -> Error err
member this.Bind(m: string, f): Result<'t,JsonError> =
match m
|> value.TryGetProperty with
| Some x -> x |> f
| None -> Error (JsonError.MissingProperty (m, value))
member this.Bind(m: string option, f) =
m |> Option.bind value.TryGetProperty |> f
member this.Bind<'u>(m: (string * (JsonValue -> JsonResult<'u>)), f): Result<'t,JsonError> =
let result = Require m value
match result with
| Ok x -> f x
| Error err -> Error err
member this.Bind<'u>(m: JsonValue -> JsonResult<'u>, f): Result<'t,JsonError> =
let result = m value
match result with
| Ok x -> f x
| Error err -> Error err
member this.Bind(m: JsonValue -> JsonResult<'u> option, f) =
match value |> m with
| Some x ->
match x with
| Ok y -> Some y |> f
| Error err -> Error err
| None -> None |> f
member this.Return(x) =
Ok x
// make an instance of the workflow
let json value = new JsonBuilder<_>(value)
All of that code does several things:
- Using this let! inside the epxression will attempt to get the JsonValue’s property “weight” as a decimal.
- If the property does not exist or is the wrong type, then the entire computation expression will stop right there and return that JsonError.
- If that property “weight” does exist and is the right type, then the next line in the computation expression will be tried.
let! weight = "weight",JsonAsDecimal
- The computation expression has this signature (defining the parameters of the function, what it returns)
JsonValue -> JsonResult<Weapon>
- By conforming all functions doing object parsing to the above function signature, these expressions may be nested.
The file of items may be returned as an array of items with these functions JsonValue -> JsonResult<Item []>
let JsonAsSubItem (itemType: ItemType) (value: JsonValue) =
match itemType with
| ItemType.Weapon ->
json value {
let! baseRange = "baserange",JsonAsInteger
let! rangeType = "rangetype",JsonAsEnum<WeaponRangeType>
let! handCount = "handcount",JsonAsInteger
let! weight = "weight",JsonAsDecimal
return {
Weapon.BaseRange = baseRange
RangeType = rangeType
HandCount = handCount
Weight = weight
} |> SubItem.Weapon
}
| ItemType.Wearable ->
json value {
let! wearableType = "wearabletype",JsonAsEnum<WearableType>
let! weight = "weight",JsonAsDecimal
return {
Wearable.Weight = weight
WearableType = wearableType
} |> SubItem.Wearable
}
| ItemType.Consumable ->
json value {
let! uses = "uses",JsonAsInteger
return {
Consumable.Uses = uses
} |> SubItem.Consumable
}
| _ -> Error (JsonError.UnexpectedType("subitem", value))
let JsonAsItems (value: JsonValue) =
value
|> JsonAsArray(fun item ->
json item {
let! itemID = "id",JsonAsInteger
let! noun = "noun",JsonAsString
let! isUnique = "isunique",JsonAsBoolean
let! itemType = "itemtype",JsonAsEnum<ItemType>
let! subItem = "subitem",(JsonAsSubItem itemType)
let! tags = "tags",JsonAsTagsMap
return {
Item.ID = itemID
Noun = noun
IsUnique = isUnique
ItemType = itemType
SubItem = subItem
Tags = tags
}
}
)
Here’s the data file for “items.json”
[
{ "id": "0",
"noun": "short sword",
"isunique": "false",
"itemtype": "0",
"subitem": {
"rangetype": "0",
"baserange": "1",
"handcount": "1",
"weight": "5.0"
},
"tags": {
}
}
]
The end result of parsing the above file with the functions above would be
JsonResult<Item>.Ok(
[|
{ ID = 0;
SubItem = Weapon { RangeType = Melee;
BaseRange = 1;
HandCount = 1;
Weight = 5.0M;};
ItemType = Weapon;
Noun = "short sword";
IsUnique = false;
Tags = map [];
}
|]
Now let’s change “itemtype” to a bad value “wasdwasd”. I’d get a tolerable message as a result
JsonResult<Item>.Error(
InconsistentArray
[| UnexpectedType ("ItemType","wasdwasd")
|]
)
Summary
Loading JSON files no longer throws exceptions in my code. Yes, there’s still the potential for malformed JSON to cause errors, and then these errors are handled gracefully. Reading this JSON is functional
let JsonAsScenario (basePath: string) (value: JsonValue) =
json value {
let! version = "version",JsonAsString
let! date = "date",JsonAsDateTime
let! name = "name",JsonAsString
let! description = "description",JsonAsString
return {
Scenario.BasePath = basePath
Version = version
Name = name
Date = date
Description = description
}
}