Skip to content

Latest commit

 

History

History
555 lines (404 loc) · 19.8 KB

Customizing.md

File metadata and controls

555 lines (404 loc) · 19.8 KB

Customizing the serialization format

How to apply customizations

The way to customize the serialization format depends on how FSharp.SystemTextJson is used.

  • When passing a JsonFSharpConverter to the JsonSerializerOptions (the recommended way), the converter takes customization options as arguments.

    let options = JsonSerializerOptions()
    JsonFSharpConverter(
        unionEncoding = JsonUnionEncoding.InternalTag,
        unionTagName = "type"
    )
    |> options.Converters.Add

    Additionally, the format can be set for specific types. The argument overrides takes an IDictionary<System.Type, JsonFSharpOptions> that lists the customized types. JsonFSharpOptions is an object whose constructor takes the same arguments as JsonFSharpConverter.

    let options = JsonSerializerOptions()
    JsonFSharpConverter(
        // These options apply by default to all types:
        unionEncoding = JsonUnionEncoding.InternalTag,
        unionTagName = "type",
        // These options apply to specific types:
        overrides = dict [
            typeof<MySpecialUnion>, JsonFSharpOptions(unionTagName = "tag")
            typeof<AnotherUnion>, JsonFSharpOptions(unionEncoding = JsonUnionEncoding.Default)
        ]
    )

    Finally, passing the argument allowOverride = true allows the custom format to be overridden by a JsonFSharpConverterAttribute (see below). By default (ie when allowOverride = false), when using JsonFSharpConverter, JsonFSharpConverterAttribute is ignored.

  • When using JsonFSharpConverterAttribute, the attribute itself takes customization options as arguments.

    [<JsonFSharpConverter(unionEncoding = JsonUnionEncoding.InternalTag)>]
    type MySpecialUnion =
        | MyCase of int
        | MyOtherCase of string

    Note that due to .NET limitations on the types of arguments that can be passed to an attribute, some options are unavailable for JsonFSharpConverterAttribute, such as unionTagNamingPolicy.

unionEncoding

The customization option unionEncoding defines the format used to encode discriminated unions. Its type is JsonUnionEncoding, and it is an enum of flags that can be combined using the binary "or" operator (|||).

A union encoding should be the combination of a base encoding and any number of additional options. For example:

let options = JsonSerializerOptions()
JsonFSharpConverter(
    unionEncoding = (
        // Base encoding:
        JsonUnionEncoding.InternalTag
        // Additional options:
        ||| JsonUnionEncoding.UnwrapOption
        ||| JsonUnionEncoding.UnwrapRecordCases
    )
)
|> options.Converters.Add

Examples in this section will serialize values of the following type:

type Example =
    | NoArgs
    | WithOneArg of aFloat: float
    | WithArgs of anInt: int * aString: string

Base encoding

There are four base encodings available. These encodings and their names are inspired by Rust's excellent Serde library, although they differ in some specifics.

AdjacentTag

JsonUnionEncoding.AdjacentTag is the default format.

It represents unions similarly to the Json.NET library. A union value is serialized into a JSON object with the following fields:

  • A field "Case" whose value is a string representing the name of the union case;
  • If the case has fields, a field "Fields" whose value is an array.

For example:

JsonSerializer.Serialize(NoArgs, options)
// --> {"Case":"NoArgs"}

JsonSerializer.Serialize(WithOneArg 3.14, options)
// --> {"Case":"WithOneArg","Fields":[3.14]}

JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
// --> {"Case":"WithArgs","Fields":[123,"Hello, world!"]}

The names "Case" and "Fields" can be customized (see unionTagName and unionFieldsName below).

ExternalTag

JsonUnionEncoding.ExternalTag represents unions similarly to FSharpLu.Json. A union value is serialized as a JSON object with one field, whose name is the name of the union case, and whose value is an array.

JsonSerializer.Serialize(NoArgs, options)
// --> {"NoArgs":[]}

JsonSerializer.Serialize(WithOneArg 3.14, options)
// --> {"WithOneArg":[3.14]}

JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
// --> {"WithArgs":[123,"Hello, world!"]}

InternalTag

JsonUnionEncoding.InternalTag represents unions similarly to Thoth.Json. A union value is serialized as a JSON array whose first item is the name of the case, and the rest are its fields.

JsonSerializer.Serialize(NoArgs, options)
// --> ["NoArgs"]

JsonSerializer.Serialize(WithOneArg 3.14, options)
// --> ["WithOneArg",3.14]

JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
// --> ["WithArgs",123,"Hello, world!"]

Untagged

JsonUnionEncoding.Untagged represents unions as an object whose fields have the names and values of the union's fields. 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(WithOneArg 3.14, options)
// --> {"aFloat":3.14}

JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
// --> {"anInt":123,"aString":"Hello world!"}

This flag also sets the NamedFields additional flag (see below).

Additional options

NamedFields

JsonUnionEncoding.NamedFields causes the fields of a union to be encoded as a JSON object rather than an array. The properties of the object are named after the value's fields (aFloat, anInt and aString in our example). Its exact effect depends on the base format:

  • JsonUnionEncoding.AdjacentTag ||| JsonUnionEncoding.NamedFields replaces the array of case fields with an object.

    JsonSerializer.Serialize(NoArgs, options)
    // --> {"Case":"NoArgs"}
    // (same format as without NamedFields)
    
    JsonSerializer.Serialize(WithOneArg 3.14, options)
    // --> {"Case":"WithOneArg","Fields":{"aFloat":3.14}}
    //                                   ^^^^^^^^^^^^^^
    //                        Instead of [3.14]
    
    JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
    // --> {"Case":"WithArgs","Fields":{"anInt":123,"aString":"Hello, world!"}}
    //                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                      Instead of [123,"Hello, world!"]
  • JsonUnionEncoding.ExternalTag ||| JsonUnionEncoding.NamedFields replaces the array of case fields with an object.

    JsonSerializer.Serialize(NoArgs, options)
    // --> {"NoArgs":{}}
    //               ^^
    //    Instead of []
    
    JsonSerializer.Serialize(WithOneArg 3.14, options)
    // --> {"WithOneArg":{"aFloat":3.14}}
    //                   ^^^^^^^^^^^^^^
    //        Instead of [3.14]
    
    JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
    // --> {"WithArgs":{"anInt":123,"aString":"Hello, world!"}}
    //                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //      Instead of [123,"Hello, world!"]
  • JsonUnionEncoding.InternalTag ||| JsonUnionEncoding.NamedFields replaces the entire value with an object. The property containing the case's name is named "Case".

    JsonSerializer.Serialize(NoArgs, options)
    // --> {"Case":"NoArgs"}
    // Instead of ["NoArgs"]
    
    JsonSerializer.Serialize(WithOneArg 3.14, options)
    // --> {"Case":"WithOneArg","aFloat":3.14}
    // Instead of ["WithOneArg",3.14]
    
    JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
    // --> {"Case":"WithArgs","anInt":123,"aString":"Hello, world!"}
    // Instead of ["WithArgs",123,"Hello, world!"]

    The name "Case" can be customized (see unionTagName below).

  • JsonUnionEncoding.Untagged is unchanged by JsonUnionEncoding.NamedFields.

If a field doesn't have a name specified in F# code, then a default name is assigned by the compiler: Item if the case has a single field, and Item1, Item2, etc if the case has multiple fields.

UnwrapFieldlessTags

JsonUnionEncoding.UnwrapFieldlessTags represents cases that don't have any fields as a simple string.

JsonSerializer.Serialize(NoArgs, options)
// --> "NoArgs"

JsonSerializer.Serialize(WithOneArg 3.14, options)
// --> (same format as without UnwrapFiledlessTags)

JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
// --> (same format as without UnwrapFieldlessTags)

UnwrapOption

JsonUnionEncoding.UnwrapOption is enabled by default. It causes the types 'T option and 'T voption (aka ValueOption) to be treated specially.

  • The value None or ValueNone is represented as null.
  • The value Some x or ValueSome x is represented the same as x, without wrapping it in the union representation for Some.

Combined with the option IgnoreNullValues on JsonSerializerOptions, this can be used to represent optional fields: Some is a value that is present in the JSON object, and None is a value that is absent from the JSON object. Note that the same effect can also be achieved more explicitly and more safely using the Skippable type.

UnwrapSingleCaseUnions

JsonUnionEncoding.UnwrapSingleCaseUnions is enabled by default. It causes unions that have a single case with a single field to be treated as simple "wrappers", and serialized as their single field's value.

JsonSerializer.Serialize(NoArgs, options)
// --> (same format as without UnwrapSingleCaseUnions)

JsonSerializer.Serialize(WithOneArg 3.14, options)
// --> (same format as without UnwrapSingleCaseUnions)

JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
// --> (same format as without UnwrapSingleCaseUnions)

type UserId = UserId of string

JsonSerializer.Serialize(UserId "tarmil", options)
// --> "tarmil"

UnwrapSingleFieldCases

JsonUnionEncoding.UnwrapSingleFieldCases: if a union case has a single field, it is not wrapped in an array or object. The exact effect depends on the base format:

  • JsonUnionEncoding.AdjacentTag ||| JsonUnionEncoding.UnwrapSingleFieldCases:

    JsonSerializer.Serialize(NoArgs, options)
    // --> (same format as without UnwrapSingleFieldCases)
    
    JsonSerializer.Serialize(WithOneArg 3.14, options)
    // --> {"Case":"WithOneArg","Fields":3.14}
    //                                   ^^^^
    //                        Instead of [3.14] or {"aFloat":3.14}
    
    JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
    // --> (same format as without UnwrapSingleCaseUnions)
  • JsonUnionEncoding.ExternalTag ||| JsonUnionEncoding.UnwrapSingleFieldCases:

    JsonSerializer.Serialize(NoArgs, options)
    // --> (same format as without UnwrapSingleFieldCases)
    
    JsonSerializer.Serialize(WithOneArg 3.14, options)
    // --> {"WithOneArg":3.14}
    //                   ^^^^
    //        Instead of [3.14] or {"aFloat":3.14}
    
    JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
    // --> (same format as without UnwrapSingleCaseUnions)
  • JsonUnionEncoding.InternalTag ||| JsonUnionEncoding.UnwrapSingleFieldCases: no effect.

  • JsonUnionEncoding.Untagged ||| JsonUnionEncoding.UnwrapSingleFieldCases: no effect.

UnwrapRecordCases

JsonUnionEncoding.UnwrapRecordCases implicitly sets NamedFields (see above). If a union case has a single field which is a record, then this record's fields are used directly as the fields of the object representing the union. The exact effect depends on the base format:

type Coordinates = { lat: float; long: float }

type Location =
    | Address of address: string
    | ExactLocation of Coordinates
  • JsonUnionEncoding.AdjacentTag ||| JsonUnionEncoding.UnwrapRecordCases:

    JsonSerializer.Serialize(Address "5 Avenue Anatole France", options)
    // --> {"Case":"Address","Fields":{"address":"5 Avenue Anatole France"}}
    // (same as without UnwrapRecordCases)
    
    JsonSerializer.Serialize(ExactLocation { lat = 48.858; long = 2.295 })
    // --> {"Case":"ExactLocation","Fields":{"lat":48.858,"long":2.295}}
    //                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                           Instead of {"Item":{"lat":48.858,"long":2.295}}
  • JsonUnionEncoding.ExternalTag ||| JsonUnionEncoding.UnwrapRecordCases:

    JsonSerializer.Serialize(Address "5 Avenue Anatole France", options)
    // --> {"Address":{"address":"5 Avenue Anatole France"}}
    // (same as without UnwrapRecordCases)
    
    JsonSerializer.Serialize(ExactLocation { lat = 48.858; long = 2.295 })
    // --> {"ExactLocation":{"lat":48.858,"long":2.295}}
    //                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //           Instead of {"Item":{"lat":48.858,"long":2.295}}
  • JsonUnionEncoding.InternalTag ||| JsonUnionEncoding.UnwrapRecordCases:

    JsonSerializer.Serialize(Address "5 Avenue Anatole France", options)
    // --> {"Case":"Address","address":"5 Avenue Anatole France"}
    // (same as without UnwrapRecordCases)
    
    JsonSerializer.Serialize(ExactLocation { lat = 48.858; long = 2.295 })
    // --> {"Case":"ExactLocation","lat":48.858,"long":2.295}
    //                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                  Instead of {"Item":{"lat":48.858,"long":2.295}}
  • JsonUnionEncoding.Untagged ||| JsonUnionEncoding.UnwrapRecordCases:

    JsonSerializer.Serialize(Address "5 Avenue Anatole France", options)
    // --> {"address":"5 Avenue Anatole France"}
    // (same as without UnwrapRecordCases)
    
    JsonSerializer.Serialize(ExactLocation { lat = 48.858; long = 2.295 })
    // --> {"lat":48.858,"long":2.295}
    // Instead of {"Item":{"lat":48.858,"long":2.295}}

AllowUnorderedTag

JsonUnionEncoding.AllowUnorderedTag is enabled by default. It takes effect during deserialization in AdjacentTag and InternalTag modes. When it is disabled, the name of the case must be the first field of the JSON object. When it is enabled, the name of the case may come later in the object, at the cost of a slight performance penalty if it does.

For example, without AllowUnorderedTag, the following will fail to parse:

JsonSerializer.Deserialize("""{"Fields":[3.14],"Case":"WithOneArg"}""", options)
// --> Error: Failed to find union case field for Example: expected Case

Whereas with AllowUnorderedTag, it will succeed:

JsonSerializer.Deserialize("""{"Fields":[3.14],"Case":"WithOneArg"}""", options)
// --> WithOneArg 3.14

Combined flags

JsonUnionEncoding also contains a few items that combine several of the above flags.

  • JsonUnionEncoding.Default represents the encoding that is used when unionEncoding is not specified. It is equivalent to:

    JsonUnionEncoding.AdjacentTag
    ||| JsonUnionEncoding.UnwrapOption
    ||| JsonUnionEncoding.UnwrapSingleCaseUnions
    ||| JsonUnionEncoding.AllowUnorderedTag

    It is particularly useful if you want to use the default encoding with some additional options, for example:

    JsonFSharpConverter(unionEncoding = (JsonUnionEncoding.Default ||| JsonUnionEncoding.UnwrapRecordCases))
  • JsonUnionEncoding.NewtonsoftLike causes similar behavior to the library Json.NET (aka Newtonsoft.Json). It is equivalent to:

    JsonUnionEncoding.AdjacentTag
    ||| JsonUnionEncoding.AllowUnorderedTag
  • JsonUnionEncoding.ThothLike causes similar behavior to the library Thoth.Json. It is equivalent to:

    JsonUnionEncoding.InternalTag
    ||| JsonUnionEncoding.UnwrapFieldlessTags
    ||| JsonUnionEncoding.AllowUnorderedTag
  • JsonUnionEncoding.FSharpLuLike causes similar behavior to the library FSharpLu.Json in Compact mode. It is equivalent to:

    JsonUnionEncoding.ExternalTag
    ||| JsonUnionEncoding.UnwrapFieldlessTags
    ||| JsonUnionEncoding.UnwrapOption
    ||| JsonUnionEncoding.UnwrapSingleFieldCases
    ||| JsonUnionEncoding.AllowUnorderedTag

unionTagName

This option sets the name of the property that contains the union case. This affects the base encodings AdjacentTag and InternalTag ||| NamedFields. The default value is "Case".

JsonFSharpConverter(unionTagName = "type")
|> options.Converters.Add

JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
// --> {"type":"WithArgs","Fields":[123,"Hello, world!"]}

unionFieldsName

This option sets the name of the property that contains the union fields. This affects the base encoding AdjacentTag. The default value is "Fields".

JsonFSharpConverter(unionFieldsName = "value")
|> options.Converters.Add

JsonSerializer.Serialize(WithArgs (123, "Hello, world!"), options)
// --> {"Case":"WithArgs","value":[123,"Hello, world!"]}

unionTagNamingPolicy

This option sets the naming policy for union case names. See the System.Text.Json documentation about naming policies.

JsonFSharpConverter(unionTagNamingPolicy = JsonNamingPolicy.CamelCase)
|> options.Converters.Add

JsonSerializer.Serialize(WithArgs(123, "Hello, world!"), options)
// --> {"Case":"withArgs","Fields":[123,"Hello, world!"]}

unionTagCaseInsensitive

This option only affects deserialization. It makes the parsing of union case names case-insensitive.

JsonFSharpConverter(unionTagCaseInsensitive = true)
|> options.Converters.Add

JsonSerializer.Deserialize<Example> """{"Case":"wIThArgS","Fields":[123,"Hello, world!"]}"""
// --> WithArgs (123, "Hello, world!")

allowNullFields

By default, FSharp.SystemTextJson throws an exception when the following conditions are met:

  • it is deserializing a record or a union;
  • a field's JSON value is null or the field is unspecified;
  • that field's type isn't an explicitly nullable F# type (like option, voption and Skippable).

With allowNullFields = true, such a null field is also allowed if the field's type is a class.

type Point() =
    member val X = 0. with get, set
    member val Y = 0. with get, set

type Rectangle = { BottomLeft: Point; TopRight: Point }

// With allowNullFields = false: throws an exception
JsonSerializer.Deserialize<Rectangle> """{"TopRight":{"X":1,"Y":2}}"""

// With allowNullFields = true: succeeds
JsonSerializer.Deserialize<Rectangle> """{"TopRight":{"X":1,"Y":2}}"""
// --> { BottomLeft = null; TopRight = Point(X = 1., Y = 2.) }