Tarmil / FSharp.SystemTextJson

System.Text.Json extensions for F# types
MIT License
323 stars 44 forks source link

Do not use FSharp.SystemTextJson on types that do not have the [<JsonFSharpConverter>] attribute #187

Open Bananas-Are-Yellow opened 4 weeks ago

Bananas-Are-Yellow commented 4 weeks ago

I have a Bolero application and I send F# unions to JavaScript and back using IJSRuntime.InvokeAsync.

JSRuntime uses System.Text.Json and has JsonSerializerOptions with:

PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,

This means F# record fields are converted to camelCase when serializing to JS, and a case-insensitive match is used when deserializing back to an F# record. I like this behavior because it is conventional to have JS object fields in camelCase and F# record fields in PascalCase.

JsonSerializerOptions is an internal property of JSRuntime, so I can't change this behavior in any case. The code is here. Since I have no control over the JsonSerializerOptions, I have to use attributes to control the behavior of FSharp.SystemTextJson.

I am using FSharp.SystemTextJson for F# unions, so my union types have the [<JsonFSharpConverter>] attribute applied. Some of my unions contain record types, and it seems that these also get handled by FSharp.SystemTextJson even if they do not have the [<JsonFSharpConverter>] attribute.

This is a constant headache for me, because the default behavior of FSharp.SystemTextJson for records is to preserve the case of record field names. It means a type without the [<JsonFSharpConverter>] attribute behaves differently depending on whether it is serialized directly (which uses System.Text.Json) or serialized as an embedded type in a type that does have the [<JsonFSharpConverter>] attribute applied.

Each time I come across this problem, I have to add the [<JsonFSharpConverter>] to my record and then add [<JsonName>] to each field to achieve camelCase in JS to be consistent with how System.Text.Json works.

It would all be so much simpler if types with [<JsonFSharpConverter>] are handled by FSharp.SystemTextJson, and types without the attribute are handled by System.Text.Json. Is there a reason why this is not the case?

Bananas-Are-Yellow commented 4 weeks ago

Each time I come across this problem, I have to add the [<JsonFSharpConverter>] to my record and then add [<JsonName>] to each field to achieve camelCase in JS to be consistent with how System.Text.Json works.

You might think this is not a big deal, but there are times when I want to return a few values from JS as an ad-hock object to be received in F# as an anonymous type. This is a very convenient way to work.

My JS function would return:

return { name: getName(), age: getAge() };

And then my F# function would do this:

let (result: {| Name: string; Age: int |}) = js.InvokeAsync ("MyFunction", ...)
let name = result.Name
...

This works fine, but there are times when I want to handle error conditions, so I have my own version of Result for use with JS Interop:

[<Struct; JsonFSharpConverter(...)>]
type Result<'t, 'error> =
    | Ok of result: 't
    | Error of error: 'error

The union is handled by FSharp.SystemTextJson but this means the result type is now also handled by FSharp.SystemTextJson. So now I have to either break the JS convention and use object fields in PascalCase:

return { Name: getName(), Age: getAge() };

Or I have to stop using the F# anonymous type and declare an actual type with the [<JsonFSharpConverter>] attribute and [<JsonName>] on each field, just because I'm using the Result union.