Skip to content
AgileLogics
6 min read

JSON and Serialization

Prerequisites: Records

.NET's built-in System.Text.Json handles F# records, lists, arrays, maps, and options out of the box:

open System.Text.Json

type Address = { City: string; Country: string }
type Customer = { Name: string; Age: int; Tags: string list; Address: Address option }

let ada = { Name = "Ada"; Age = 36; Tags = [ "vip"; "early" ]; Address = Some { City = "London"; Country = "UK" } }

let json = JsonSerializer.Serialize ada
printfn "%s" json

let back = JsonSerializer.Deserialize<Customer> json
printfn "%b" (back = ada)

let noAddress = JsonSerializer.Deserialize<Customer> """{"Name":"Bob","Age":41,"Tags":[],"Address":null}"""
printfn "%A" noAddress.Address
{"Name":"Ada","Age":36,"Tags":["vip","early"],"Address":{"City":"London","Country":"UK"}}
true
None

Records deserialize through their constructor, so they stay immutable. Some x is written as plain x, and None as null.

Options: naming and indentation

open System.Text.Json

type Product = { ProductId: int; DisplayName: string; UnitPrice: decimal }

let options = JsonSerializerOptions(PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true)

let json = JsonSerializer.Serialize({ ProductId = 7; DisplayName = "Tea"; UnitPrice = 3.5m }, options)
printfn "%s" json

let parsed = JsonSerializer.Deserialize<Product>("""{"productId":8,"displayName":"Coffee","unitPrice":4.0}""", options)
printfn "%A" parsed
{
  "productId": 7,
  "displayName": "Tea",
  "unitPrice": 3.5
}
{ ProductId = 8
  DisplayName = "Coffee"
  UnitPrice = 4.0M }

Reuse one JsonSerializerOptions instance — creating a new one per call is slow. Paste JSON output into the JSON Formatter or JSON Validator to inspect it.

Discriminated unions need a library

Plain System.Text.Json doesn't know how to represent unions:

open System.Text.Json

type Shape =
    | Circle of radius: float
    | Square of side: float

try
    JsonSerializer.Serialize(Circle 1.5) |> printfn "%s"
with ex ->
    printfn "%s" (ex.GetType().Name)
NotSupportedException

The FSharp.SystemTextJson package adds a converter for unions (and other F# types), with several encoding styles:

// illustrative: downloads FSharp.SystemTextJson on first run
#r "nuget: FSharp.SystemTextJson"
open System.Text.Json
open System.Text.Json.Serialization

type Shape =
    | Circle of radius: float
    | Square of side: float

let options = JsonFSharpOptions.Default().ToJsonSerializerOptions()

let json = JsonSerializer.Serialize([ Circle 1.5; Square 2.0 ], options)
printfn "%s" json
printfn "%A" (JsonSerializer.Deserialize<Shape list>(json, options))
[{"Case":"Circle","Fields":[1.5]},{"Case":"Square","Fields":[2]}]
[Circle 1.5; Square 2.0]

Other popular choices are Thoth.Json (explicit encoders and decoders, which also work in Fable) and Newtonsoft.Json with Fable.JsonConverter-style converters in older code.

Reading JSON without types

For quick exploration, JsonDocument gives read-only access to any JSON:

open System.Text.Json

let doc = JsonDocument.Parse """{"user":{"name":"Ada","langs":["F#","C#"]}}"""
let user = doc.RootElement.GetProperty "user"

printfn "%s" (user.GetProperty("name").GetString())
for lang in user.GetProperty("langs").EnumerateArray() do
    printfn "- %s" (lang.GetString())
Ada
- F#
- C#
  • Validate JSON from outside your program (it's untrusted input): parse into a DTO record, then convert to domain types with smart constructors that return Result.