Welcome! I am Loïc Denuzière aka "Tarmil", and this is my technical blog. I'm a French developer who's quite passionate about functional programming, and I've been using F# for most of my work for over ten years.

I've been one of the main developers of WebSharper, the F# web framework with both server-side and JavaScript-compiled client-side tools. Recently I've been focusing more on Bolero, a library that runs F# applications in WebAssembly using Blazor, and other personal projects.

Follow me on Twitter, Mastodon and find me on GitHub.

FSharp.SystemTextJson 1.2 released!

I am happy to announce FSharp.SystemTextJson version 1.2!

FSharp.SystemTextJson is a library that provides support for F# types in .NET's standard System.Text.Json.

Here is a summary of the new features in v1.2.

Skippable Option fields (#154)

Version 1.1 introduced the method JsonFSharpOptions.WithSkippableOptionFields(?bool) that, when set to true, causes None and ValueNone to always be skipped when serialized as the value of a record or union field. However, even with this option left as false, None and ValueNone were still skipped if the JsonSerializerOptions have DefaultIgnoreCondition set to WhenWritingNull.

In version 1.2, a new overload of JsonFSharpOptions.WithSkippableOptionFields takes an enum as argument that brings more possibilities.

  • SkippableOptionFields.Always is equivalent to true: record and union fields equal to None and ValueNone are always skipped.

  • SkippableOptionFields.FromJsonSerializerOptions is equivalent to false: record and union fields equal to None and ValueNone are only skipped if JsonSerializerOptions have DefaultIgnoreCondition set to WhenWritingNull. Otherwise, they are serialized as JSON null.

  • SkippableOptionFields.Never is new: None and ValueNone are never skipped, and always serialized as JSON null.

Handling of dictionary and map keys (#161 and #162)

In version 1.2, FSharp.SystemTextJson now makes use of System.Text.Json's ReadAsPropertyName and WriteAsPropertyName features. This manifests in two ways:

  • Single-case unions can now be used as keys in a standard Dictionary (and related types).
    NOTE: This requires System.Text.Json 8.0.

    let options = JsonFSharpOptions().ToJsonSerializerOptions()
    
    type CountryCode = CountryCode of string
    
    let countries = dict [
        CountryCode "us", "United States"
        CountryCode "fr", "France"
        CountryCode "gb", "United Kingdom"
    ]
    
    JsonSerializer.Serialize(countries, options)
    // --> {"us":"United States","fr":"France","gb":"United Kingdom"}
    
  • The format for maps can now be customized using JsonFSharpOptions.WithMapFormat(MapFormat).

    • MapFormat.Object always serializes maps as objects. The key type must be supported as key for dictionaries.
      NOTE: This requires System.Text.Json 8.0.

      let options = JsonFSharpOptions().WithMapFormat(MapFormat.Object).ToJsonSerializerOptions()
      
      let countries = Map [
          Guid.NewGuid(), "United States"
          Guid.NewGuid(), "France"
          Guid.NewGuid(), "United Kingdom"
      ]
      
      JsonSerializer.Serialize(countries, options)
      // --> {"44e2a549-66c6-4515-970a-a1e85ce42624":"United States", ...
      
    • MapFormat.ArrayOfPairs always serializes maps as JSON arrays whose items are [key,value] pairs.

      let options = JsonFSharpOptions().WithMapFormat(MapFormat.ArrayOfPairs).ToJsonSerializerOptions()
      
      let countries = Map [
          "us", "United States"
          "fr", "France"
          "uk", "United Kingdom"
      ]
      
      JsonSerializer.Serialize(countries, options)
      // --> [["us","United States"],["fr","France"],["uk","United Kingdom"]]
      
    • MapFormat.ObjectOrArrayOfPairs is the default, and the same behavior as v1.1. Maps whose keys are string or single-case unions wrapping string are serialized as JSON objects, and other maps are serialized as JSON arrays whose items are [key,value] pairs.

Other improvements

  • #158: Throw an exception when trying to deserialize null into a record or union in any context, rather than only when they are in fields of records and unions.

  • #163: Add StructuralComparison to the type Skippable<_>. This allows using it with types that are themselves marked with StructuralComparison.

Bug fixes

  • #160: Fix WithSkippableOptionFields(false) not working for voption.

  • #164: When deserializing a record with JsonIgnoreCondition.WhenWritingNull, when a non-nullable field is missing, throw a proper JsonException that includes the name of the field, rather than a NullReferenceException.

Happy coding!

By Loïc "Tarmil" Denuzière on Monday, August 14, 2023

fsharp release library fsharp-systemtextjson Tweet Permalink

FSharp.SystemTextJson 1.1 released!

I am happy to announce FSharp.SystemTextJson version 1.1!

FSharp.SystemTextJson is a library that provides support for F# types in .NET's standard System.Text.Json.

Here is a summary of the new features in v1.1.

Fluent configuration

The library now comes with a new syntax for configuration. Instead of a constructor with a mix of optional arguments and enum flags, you can now use a more consistent fluent syntax.

  • The baseline options are declared using one of these static methods on the JsonFSharpOptions type: Default(), NewtonsoftLike(), ThothLike() or FSharpLuLike().
  • Then, fluent instance methods set various options and return a new instance of JsonFSharpOptions.
  • Finally, a converter with the given options can be either added to an existing JsonSerializerOptions with the method AddToJsonSerializerOptions(), or to a new one with ToJsonSerializerOptions().

For example:

let options =
    JsonFSharpOptions.Default()
        .WithUnionInternalTag()
        .WithUnionNamedFields()
        .WithUnionTagName("type")
        .ToJsonSerializerOptions()

type SomeUnion =
    | SomeUnionCase of x: int * y: string

JsonSerializer.Serialize(SomeUnionCase (1, "test"), options)
// --> {"type":"SomeUnionCase","x":1,"y":"test"}

Note that in the future, newly added options will be available via the fluent configuration, but they may not always be added to the constructor syntax; especially because this can break binary compatibility (see this issue).

Skippable option fields

In version 1.0, the default behavior for fields of type option and voption changed: they are no longer serialized as a present or missing field, and instead as a null field.

While the pre-1.0 behavior can be recovered by using the JsonSerializerOptions property DefaultIgnoreCondition, this has other side-effects and multiple users have asked for a cleaner way to use options for missing fields.

This is now possible with the option SkippableOptionFields. This is the first option that is only available via fluent configuration, and not as a JsonFSharpConverter constructor argument.

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

JsonSerializer.Serialize({| x = Some 42; y = None |}, options)
// --> {"x":42}

Happy coding!

By Loïc "Tarmil" Denuzière on Saturday, January 21, 2023

fsharp release library fsharp-systemtextjson Tweet Permalink

FSharp.SystemTextJson 1.0 released!

More than three years after the first release, I am happy to announce FSharp.SystemTextJson version 1.0!

FSharp.SystemTextJson is a library that provides support for F# types in .NET's standard System.Text.Json.

Here is a summary of the new features in v1.0.

JsonName attribute

System.Text.Json provides an attribute JsonPropertyName to change the name of a property in JSON. In FSharp.SystemTextJson 1.0, the new attribute JsonName is equivalent but provides more functionality:

  • When used on a discriminated union case, JsonName can take a value of type int or bool instead of string.

    type MyUnion =
        | [<JsonName 1>] One of x: int
        | [<JsonName 2>] Two of y: string
    
    let options = JsonSerializerOptions()
    options.Converters.Add(JsonFSharpConverter(JsonUnionEncoding.Default ||| JsonUnionEncoding.InternalTag ||| JsonUnionEncoding.NamedFields))
    JsonSerializer.Serialize(Two "two", options)
    // => {"Case":2,"x":"two"}
    
  • JsonName can take multiple values. When deserializing, all these values are treated as equivalent. When serializing, the first one is used.

    type Name =
        { [<JsonName("firstName", "first")>]
          First: string
          [<JsonName("lastName", "last")>]
          Last: string }
    
    let options = JsonSerializerOptions()
    options.Converters.Add(JsonFSharpConverter())
    JsonSerializer.Deserialize<Name>("""{"first":"John","last":"Doe"}""", options)
    // => { First = "John"; Last = "Doe" }
    
    JsonSerializer.Serialize({ First = "John"; Last = "Doe" }, options)
    // => {"firstName":"John","lastName":"Doe"}
    
  • JsonName has a settable property Field: string. It is used to set the JSON name of a union case field with the given name.

    type Contact =
        | [<JsonName("email", Field = "address")>]
          Email of address: string
        | Phone of number: string
    
    let options = JsonSerializerOptions()
    options.Converters.Add(JsonFSharpConverter(JsonUnionEncoding.Default ||| JsonUnionEncoding.InternalTag ||| JsonUnionEncoding.NamedFields))
    JsonSerializer.Serialize(Email "john.doe@example.com")
    // => {"Case":"Email","email":"john.doe@example.com"}
    

Record properties

By default, FSharp.SystemTextJson only serializes the fields of a record. There are now two ways to also serialize their properties:

  • The option includeRecordProperties: bool enables serializing all record properties (except those that have the attribute JsonIgnore, just like fields).

    type User =
        { id: int
          name: string }
    
        member this.profileUrl = $"https://example.com/user/{this.id}/{this.name}"
    
        [<JsonIgnore>]
        member this.notIncluded = "This property is not included"
    
    let options = JsonSerializerOptions()
    options.Converters.Add(JsonFSharpConverter(includeRecordProperties = true))
    JsonSerializer.Serialize({ id = 1234; name = "john.doe" })
    // => {"id":1234,"name":"john.doe","profileUrl":"https://example.com/user/1234/john.doe"}
    
  • The attribute JsonInclude can be used on a specific property to serialize it.

    type User =
        { id: int
          name: string }
    
        [<JsonInclude>]
        member this.profileUrl = $"https://example.com/user/{this.id}/{this.name}"
    
        member this.notIncluded = "This property is not included"
    
    let options = JsonSerializerOptions()
    options.Converters.Add(JsonFSharpConverter())
    JsonSerializer.Serialize({ id = 1234; name = "john.doe" })
    // => {"id":1234,"name":"john.doe","profileUrl":"https://example.com/user/1234/john.doe"}
    

BREAKING CHANGE: Missing fields

In FSharp.SystemTextJson 0.x, using default options, missing fields of type option or voption would be deserialized into None or ValueNone. This was unintended behavior, which is corrected in version 1.0: these missing fields now throw an error. To restore the previous behavior, either enable the option IgnoreNullValues = true, or or use the type Skippable instead of option or voption.

Additionally, the option DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull is now treated as a synonym for IgnoreNullValues = true.

type Name =
    { firstName: string
      lastName: string option }

let options = JsonSerializerOptions()
options.Converters.Add(JsonFSharpConverter())
JsonSerializer.Deserialize<Name>("""{"firstName":"John"}""", options)
// => JsonException

let options2 = JsonSerializerOptions(IgnoreNullValues = true)
options2.Converters.Add(JsonFSharpConverter())
JsonSerializer.Deserialize<Name>("""{"firstName":"John"}""", options2)
// => { firstName = "John"; lastName = None }

let options3 = JsonSerializerOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)
options3.Converters.Add(JsonFSharpConverter())
JsonSerializer.Deserialize<Name>("""{"firstName":"John"}""", options3)
// => { firstName = "John"; lastName = None }

type NameWithSkippable =
    { firstName: string
      lastName: Skippable<string> }

let options4 = JsonSerializerOptions()
options4.Converters.Add(JsonFSharpConverter())
JsonSerializer.Deserialize<Name>("""{"firstName":"John"}""", options4)
// => { firstName = "John"; lastName = Skip }

Built-in support in .NET 6 and JsonFSharpTypes

In .NET 6, support has been added in System.Text.Json for a number of F# types. This support is different from FSharp.SystemTextJson in a number of ways:

  • Records, tuples, lists, sets, maps: null is accepted by the deserializer, and returns a null value.
  • Records: missing fields are deserialized to default value instead of throwing an error.
  • Maps: only primitive keys are supported. Numbers and booleans are converted to string and used as JSON objet keys.
  • Tuples: only supports up to 8 items, and serializes it as a JSON object with keys "Item1", "Item2", etc.
  • Discriminated unions, struct tuples: not supported.

FSharp.SystemTextJson takes over the serialization of these types by default; but the option types: JsonFSharpTypes allows customizing which types should be serialized by FSharp.SystemTextJson, and which types should be left to System.Text.Json.

let options = JsonSerializerOptions()
// Only use FSharp.SystemTextJson for records and unions:
options.Converters.Add(JsonFSharpOptions(types = (JsonFSharpTypes.Records ||| JsonFSharpTypes.Unions)))

JsonSerializer.Serialize(Map [(1, "one"); (2, "two")], options)
// => {"1":"one","2":"two"}
// whereas FSharp.SystemTextJson would have serialized as:
// => [[1,"one"],[2,"two"]]

Happy coding!

By Loïc "Tarmil" Denuzière on Sunday, September 25, 2022

fsharp release library fsharp-systemtextjson Tweet Permalink