Tarmil / FSharp.SystemTextJson

System.Text.Json extensions for F# types
MIT License
329 stars 45 forks source link

Discriminated union serialized to object-compatible json #103

Closed kspeakman closed 2 years ago

kspeakman commented 2 years ago

Most of the available representations for DUs were patterned after reflection api arguments rather than general utility. It is possible to serialize DUs into object-compatible JSON. Which means that if absolutely necessary, another language without DU support is capable of deserializing the produced JSON into a statically typed object without a special converter. (And it seems no worse for dynamic languages either.) So a DU like this:

// F#
module EventData =
    type AuditStarted { By: int }
    type AuditComplete { Summary: string; Files: int list; }

open EventData

type AuditEvent =
    | AuditStarted of AuditStarted
    | AuditCompleted of AuditCompleted
    | AuditCanceled

Would get serialized like this:

// AuditStarted { By = 123 }
{ "auditStarted": { "by": 123 } }

// AuditCompleted { Summary = "Findings normal."; Files = [] }
{ "auditCompleted": { "summary": "Findings normal.", "files": [] } }

// AuditCanceled
{ "auditCanceled": { } }

And could be deserialized into a DTO like this:

// C#
public class AuditStarted { ... } // similar definition to F# record
public class AuditCompleted { ... } // similar definition to F# record
public class AuditCanceled {} // F# doesn't let you do this with records

public class AuditEvent
{
    public AuditStarted? AuditStarted {get; set;}
    public AuditCompleted? AuditCompleted {get; set;}
    public AuditCanceled? AuditCanceled {get; set;}
}

This isn't an ideal representation of course. It's memory inefficient and fiddly to use. But it will scrape by. And with a small effort, a custom converter can turn the json into shallow inheritance classes (like F#'s internal representation of DUs) or marker interface classes in an obvious way.

I couldn't see a combination of the existing encodings that had similar compatibility. Adjacent doesn't have a helpful direct representation in typed languages (Fields becomes a boxed object array). Most encodings use heterogeneous arrays for case values. These make sense for 2+ item tuples. But are unhelpful for 0 or 1 parameters -- how to make 0- or 1-tuples? or an empty array of what? or extra array wrapper around a single value. That's why I used empty object for no case args. (Using the simple case name string is hard to represent compatibly with other cases that have args, and raises questions with camel casing.) And for 1 arg the unwrapped value makes the most sense -- don't force the consumer to index into an array when it will only ever have 1 value.

I'm probably out on a limb with this -- I fully expect I'll have to find/adapt a converter if I ever switch to STJ. So I hope you will at least find this perspective interesting. Best wishes.

Tarmil commented 2 years ago

I believe ExternalTag ||| NamedFields ||| UnwrapRecordCases achieves the encoding you proposed.

Otherwise, if you're willing to use a converter on the object-oriented language side, something like this seems compatible with InternalTag ||| NamedFields ||| UnwrapRecordCases for a more natural inheritance-based representation. I would be surprised if other languages don't have similar solutions; serializing type hierarchies is a commonly-asked feature.

kspeakman commented 2 years ago

Apologies as I didn't notice that specific combination. After a bit more tracing through the examples, this does almost exactly what I described. Perhaps even better overall compatibility with inheritance schemes and languages lacking tuple support. Cheers.