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