Search code examples
jsonf#json.netsystem.text.json

Serialize Record members with System.Text.Json


I am using self-referencing members on my records like this:

type Payload =
    { Id: Guid }
    member x.DerivedProperty = $"Derived Property using id: {x.Id}"

NewtonSoft.Json would serialize this, but after migrating to System.Text.Json it is not longer included when serializing.

Is it possible to configure System.Text.Json to include self-referencing members?

Edit: This appears to be a side effect of FSharp.System.Text.Json: https://github.com/Tarmil/FSharp.SystemTextJson/issues/92

Looking for a workaround. Note that if I do not serialize Payload with FSharp.System.Text.Json then it is serialized as expected.


Solution

  • As you pointed out in your question, the failure to serialize record member properties is a known limitation of JsonFSharpConverter from FSharp.SystemTextJson, see Serializing member properties on record types #92:

    The output does not contain the member properties. Remove the converter in the options and it does get printed out.

    But what does JsonFSharpConverter actually do for record types? As it turns out, this converter is a factory which manufactures a JsonRecordConverter<'T> for f# record types. From inspection of its source, the main advantage JsonRecordConverter<'T> brings is that it always constructs a record using its parameterized constructor. This is required for all records in .NET Core 3.1, and may still be required for internal records in .NET 5 and later.[1] In addition it may also be required to properly serialize any field at all of an internal record -- I haven't tested this. [2]

    Since it seems you do not need the converter applied for your Payload type, you can opt out of using JsonFSharpConverter by encapsulating it in some decorator factory that whose CanConvert(Type) method returns false for types for which you want to use default serialization.

    For instance, you could create a converter factory that opts out of FSharp.System.Text.Json when some custom attribute is applied:

    type JsonConverterFactoryDecorator (innerConverter : JsonConverterFactory) =
        inherit JsonConverterFactory ()
        member private this.innerConverter = match innerConverter with | null -> nullArg "innerConverter" | _ -> innerConverter // Guard against null if called from c# serialization code
        override this.CanConvert(t) = innerConverter.CanConvert(t)
        override this.CreateConverter(typeToConvert, options) = innerConverter.CreateConverter(typeToConvert, options)
    
    type OptOutJsonConverterFactoryDecorator<'T when 'T :> System.Attribute> (innerConverter : JsonConverterFactory) =
        inherit JsonConverterFactoryDecorator (innerConverter)
        override this.CanConvert(t) = base.CanConvert(t) && not (t.IsDefined(typeof<'T>, false))
    
    type JsonFSharpConverterOptOutAttribute () =
        inherit System.Attribute()
    
    type OptOutJsonFSharpConverter () = 
        inherit OptOutJsonConverterFactoryDecorator<JsonFSharpConverterOptOutAttribute>(JsonFSharpConverter())
    

    Then decorate Payload as follows:

    [<JsonFSharpConverterOptOut>]
    type Payload =
        { Id: Guid }
        member x.DerivedProperty = "Derived Property using id: {x.Id}"
    

    Or if you prefer to opt out of FSharp.System.Text.Json for all records, define OptOutJsonFSharpConverter as follows:

    type OptOutJsonFSharpConverter () = 
        inherit JsonConverterFactoryDecorator(JsonFSharpConverter())
        override this.CanConvert(t) = base.CanConvert(t) && not (Microsoft.FSharp.Reflection.FSharpType.IsRecord(t, BindingFlags.Public ||| BindingFlags.NonPublic))
    

    (You may need to experiment to get the most appropriate serialization for your records, e.g. you may need to check t.IsPublic to re-enable JsonFSharpConverter for internal records.)

    Whichever version of OptOutJsonFSharpConverter you choose, initialize your options as follows:

    let options = JsonSerializerOptions()
    options.Converters.Add(OptOutJsonFSharpConverter ())
    

    [1] See F# internal visibility changes Record constructor behavior for details.

    [2] See Fields and methods on internal types are impossible to make public #2820