This library provides F# union and record support for System.Text.Json.
The NuGet package is FSharp.SystemTextJson
. However the namespace is System.Text.Json.Serialization
like the base library.
There are two ways to use FSharp.SystemTextJson
: apply it to all F# types by passing JsonSerializerOptions
, or apply it to specific types with an attribute.
Add JsonFSharpConverter
to the converters in JsonSerializerOptions
, and the format will be applied to all F# types.
open System.Text.Json
open System.Text.Json.Serialization
let options = JsonSerializerOptions()
options.Converters.Add(JsonFSharpConverter())
JsonSerializer.Serialize({| x = "Hello"; y = "world!" |}, options)
// --> {"x":"Hello","y":"world!"}
Add JsonFSharpConverterAttribute
to the type that needs to be serialized.
open System.Text.Json
open System.Text.Json.Serialization
[<JsonFSharpConverter>]
type Example = { x: string; y: string }
JsonSerializer.Serialize({ x = "Hello"; y = "world!" })
// --> {"x":"Hello","y":"world!"}
The options way is generally recommended because it applies the format to all F# types. In addition to your defined types, this also includes:
- Types defined in referenced libraries that you can't modify to add an attribute. This includes standard library types such as
option
andResult
. - Anonymous records.
The attribute way cannot handle the above cases.
The advantage of the attribute way is that it allows calling Serialize
and Deserialize
without having to pass options every time. This is particularly useful if you are passing your own data to a library that calls these functions itself and doesn't take options.
ASP.NET Core can be easily configured to use FSharp.SystemTextJson.
To use F# types in MVC controllers, add the following to your startup ConfigureServices
:
member this.ConfigureServices(services: IServiceCollection) =
services.AddControllersWithViews() // or whichever method you're using to get an IMvcBuilder
.AddJsonOptions(fun options ->
options.JsonSerializerOptions.Converters.Add(JsonFSharpConverter()))
|> ignore
And you can then just do:
type MyTestController() =
inherit Controller()
member this.AddOne([<FromBody>] msg: {| value: int |}) =
{| value = msg.value + 1 |}
To use F# types in SignalR hubs, add the following to your startup ConfigureServices
:
member this.ConfigureServices(services: IServiceCollection) =
services.AddSignalR()
.AddJsonProtocol(fun options ->
options.PayloadSerializerOptions.Converters.Add(JsonFSharpConverter()))
|> ignore
And you can then just do:
type MyHub() =
inherit Hub()
member this.AddOne(msg: {| value: int |})
this.Clients.All.SendAsync("AddedOne", {| value = msg.value + 1 |})
F# lists are serialized as JSON arrays.
JsonSerializer.Serialize([1; 2; 3], options)
// --> [1,2,3]
F# sets are serialized as JSON arrays.
JsonSerializer.Serialize(Set [1; 2; 3], options)
// --> [1,2,3]
F# string-keyed maps are serialized as JSON objects.
JsonSerializer.Serialize(Map [("a", 1); ("b", 2); ("c", 3)], options)
// --> {"a":1,"b":2,"c":3}
Maps with other types as keys are serialized as JSON arrays, where each item is a [key,value]
array.
JsonSerializer.Serialize(Map [(1, "a"); (2, "b"); (3, "c")], options)
// --> [[1,"a"],[2,"b"],[3,"c"]]
Tuples and struct tuples are serialized as JSON arrays.
JsonSerializer.Serialize((1, "abc"), options)
// --> [1,"abc"]
JsonSerializer.Serialize(struct (1, "abc"), options)
// --> [1,"abc"]
Records and anonymous records are serialized as JSON objects.
type Example = { x: string; y: string }
JsonSerializer.Serialize({ x = "Hello"; y = "world!" }, options)
// --> {"x":"Hello","y":"world!"}
JsonSerializer.Serialize({| x = "Hello"; y = "world!" |}, options)
// --> {"x":"Hello","y":"world!"}
Named record fields are serialized in the order in which they were declared in the type declaration.
Anonymous record fields are serialized in alphabetical order.
Unions can be serialized in a number of formats. The enum JsonUnionEncoding
defines the format to use; you can pass a value of this type to the constructor of JsonFSharpConverter
or to the JsonFSharpConverter
attribute.
// Using options:
let options = JsonSerializerOptions()
options.Converters.Add(JsonFSharpConverter(JsonUnionEncoding.InternalTag ||| JsonUnionEncoding.UnwrapFieldlessTags))
// Using attributes:
[<JsonFSharpConverter(JsonUnionEncoding.InternalTag ||| JsonUnionEncoding.UnwrapFieldlessTags)>]
type MyUnion = // ...
Some of the encoding styles for Unions write their union case name and/or argument values to a configurable tag.
To configure this, JsonFSharpConverter
accepts arguments called unionTagName
and unionFieldsName
, respectively.
These tags default to "Case"
and "Fields"
respectively, and can be configured like so:
// Using options:
let options = JsonSerializerOptions()
options.Converters.Add(JsonFSharpConverter(JsonUnionEncoding.AdjacentTag ||| JsonUnionEncoding.NamedFields, "alternative-case-tag", "alternative-fields-tag"))
// Using attributes:
[<JsonFSharpConverter(JsonUnionEncoding.AdjacentTag ||| JsonUnionEncoding.NamedFields, "alternative-case-tag", "alternative-fields-tag")>]
type MyUnion = // ...
Note that when using the default JsonUnionEncoding
, unionTagName
and unionFieldsName
can be passed by key, rather than by position, e.g.:
[<JsonFSharpConverter(unionTagName="alternative-case-tag", unionFieldsName="alternative-fields-tag")>]
type MyUnion = // ...
Here are the possible values:
-
JsonUnionEncoding.AdjacentTag
is the default format. It represents unions as a JSON object with two fields:- A field named from
unionTagName
: a string whose value is the name of the union case. - A field named from
unionFieldsName
: an array whose items are the arguments of the union case. This field is absent if the union case has no arguments.
This is the same format used by Newtonsoft.Json.
type Example = | WithArgs of anInt: int * aString: string | NoArgs JsonSerializer.Serialize(NoArgs, options) // --> {"Case":"NoArgs"} JsonSerializer.Serialize(WithArgs (123, "Hello world!"), options) // --> {"Case":"WithArgs","Fields":[123,"Hello world!"]}
- A field named from
-
JsonUnionEncoding.AdjacentTag ||| JsonUnionEncoding.NamedFields
is similar, except that the fields are represented as an object instead of an array. The field names on this object are the names of the arguments.JsonSerializer.Serialize(NoArgs, options) // --> {"Case":"NoArgs"} JsonSerializer.Serialize(WithArgs (123, "Hello world!"), options) // --> {"Case":"WithArgs","Fields":{"anInt":123,"aString":"Hello world!"}}
Note that if an argument doesn't have an explicit name, F# automatically gives it the name
Item
(if it's the only argument of its case) orItem1
/Item2
/etc (if the case has multiple arguments). -
JsonUnionEncoding.ExternalTag
represents unions as a JSON object with one field, whose name is the name of the union case, and whose value is an array whose items are the arguments of the union case.This is the same format used by FSharpLu.Json.
JsonSerializer.Serialize(NoArgs, options) // --> {"NoArgs":[]} JsonSerializer.Serialize(WithArgs (123, "Hello world!"), options) // --> {"WithArgs":[123,"Hello world!"]}
-
JsonUnionEncoding.ExternalTag ||| JsonUnionEncoding.NamedFields
is similar, except that the fields are represented as an object instead of an array.JsonSerializer.Serialize(NoArgs, options) // --> {"NoArgs":{}} JsonSerializer.Serialize(WithArgs (123, "Hello world!"), options) // --> {"WithArgs":{"anInt":123,"aString":"Hello world!"}}
-
JsonUnionEncoding.InternalTag
represents unions as an array whose first item is the name of the case, and the rest of the items are the arguments.This is the same format used by Thoth.Json.
JsonSerializer.Serialize(NoArgs, options) // --> ["NoArgs"] JsonSerializer.Serialize(WithArgs (123, "Hello world!"), options) // --> ["WithArgs",123,"Hello world!"]
-
JsonUnionEncoding.InternalTag ||| JsonUnionEncoding.NamedFields
represents unions as an object whose first field isunionTagName
(defaulting to"Case"
), and whose value is the name of the case. The rest of the fields have the names and values of the arguments.JsonSerializer.Serialize(NoArgs, options) // --> {"Case":"NoArgs"} JsonSerializer.Serialize(WithArgs (123, "Hello world!"), options) // --> {"Case":"WithArgs","anInt":123,"aString":"Hello world!"}
-
JsonUnionEncoding.Untagged
represents unions as an object whose fields have the names and values of the arguments. The name of the case is not encoded at all. Deserialization is only possible if the fields of all cases have different names.JsonSerializer.Serialize(NoArgs, options) // --> {} JsonSerializer.Serialize(WithArgs (123, "Hello world!"), options) // --> {"anInt":123,"aString":"Hello world!"}
-
Additionally, or-ing
||| JsonUnionEncoding.UnwrapFieldlessTags
to any of the previous formats represents cases that don't have any arguments as a simple string.JsonSerializer.Serialize(NoArgs, options) // --> "NoArgs" JsonSerializer.Serialize(WithArgs (123, "HelloWorld!"), options) // --> (same format as without UnwrapFieldlessTags)
Union cases that are represented as null
in .NET using CompilationRepresentationFlags.UseNullAsTrueValue
, such as Option.None
, are serialized as null
.
By default, the type 'T option
is treated specially.
-
The value
None
, as mentioned above, is represented asnull
. -
The value
Some x
is represented the same asx
, without wrapping it in the union representation forSome
.
When using a custom JsonUnionEncoding
, this behavior is enabled by or-ing ||| JsonUnionEncoding.UnwrapOption
.
- Does FSharp.SystemTextJson support struct records and unions?
Yes!
- Does FSharp.SystemTextJson support anonymous records?
Yes!
- Does FSharp.SystemTextJson support alternative formats for unions?
- Does FSharp.SystemTextJson support representing
'T option
as either just'T
ornull
(or an absent field)?
Yes! Starting with v0.6, this is the default behavior.
To supersede it, use an explicit JsonUnionEncoding
that does not include UnwrapOption
.
- Does FSharp.SystemTextJson support
JsonPropertyNameAttribute
andJsonIgnoreAttribute
on record fields?
Yes!
- Does FSharp.SystemTextJson support options such as
PropertyNamingPolicy
andIgnoreNullValues
?
Yes!
- Does FSharp.SystemTextJson allocate memory?
As little as possible, but unfortunately the FSharp.Reflection
API requires some allocations. In particular, an array is allocated for as many items as the record fields or union arguments, and structs are boxed. There is work in progress to improve this.
- Are there any benchmarks, e.g. against Newtonsoft.Json?