Skip to content

System.Text.Json : Consider supporting F# discriminated unions #55744

Closed

Description

@eiriktsarpalis

Concerning support for discriminated unions, it might be worth pointing out that there is no one canonical way to encode DUs in JSON. DUs are used in many different ways in F# code, including the following:

  • Single case DUs, e.g. type Email = Email of string, used to provide a type-safe wrapper for common values. A user might expect that Email("email") should serialize as its payload, "email".
  • Type-safe enums, e.g. type Suit = Heart | Spade | Diamond | Club. Users might reasonably expect that Spade should serialize as the union case identifier, "Spade".
  • Unions with arbitrary combinations of union cases and arities, e.g. type Shape = Point | Circle of radius:float | Rectangle of width:float * length:float. A value like Circle(42.) would require an encoding similar to { "shape" : "circle", "radius" : 42 }. Users should be able to specify the case discriminator property name ("shape") as well as the identifier for the union case (Circle mapping to "circle").

In light of the above, I'm increasingly starting to think that System.Text.Json should not be providing a default mechanism for serializing DUs. Users would still be able to use available custom converters that provide the union encoding that suits their use case or perhaps use unions in their data contracts in a way that bypasses the serialization layer altogether.

Originally posted by @eiriktsarpalis in #29812 (comment)

If we do decide to support F# DUs in the future, it would likely be in the form of a publicly available converter factory à la JsonStringEnumConverter that configures one or more of the above alternative serialization formats.

Activity

  1. ghost added
    untriagedNew issue has not been triaged by the area owner
    on Jul 15, 2021
  2. added this to the 7.0.0 milestone on Jul 15, 2021
  3. changed the title [-]Consider supporting F# discriminated unions[/-] [+]System.Text.Json : Consider supporting F# discriminated unions[/+] on Jul 15, 2021
  4. added
    api-needs-workAPI needs work before it is approved, it is NOT ready for implementation
    and removed
    untriagedNew issue has not been triaged by the area owner
    on Jul 15, 2021
  5. eiriktsarpalis commented on Jul 15, 2021

    @eiriktsarpalis
    MemberAuthor

    Tagging @bartelink @Zaid-Ajaj @NinoFloris who might have opinions on the matter.

  6. Zaid-Ajaj commented on Jul 15, 2021

    @Zaid-Ajaj

    it might be worth pointing out that there is no one canonical way to encode DUs in JSON.

    IMO just because there are multiple ways to represent DUs in JSON doesn't imply that a library shouldn't pick a default. I think many F# devs would appreciate a choice that works out of the box with the possibility of customization where required, not having to customize or having to understand library internals to get started with it.

    This is the approach I implemented in Fable.Remoting.Json which is a Newtonsoft.Json converter for F# types without loss of information (possible to round trip). Given a Shape type like

    type Shape = 
      | Point 
      | Circle of radius:float 
      | Rectangle of width:float * length:float

    The following JSON is generated

    Point => { "Point": [] } | "Point"
    Circle(20.0) => { "Circle": [20.0] }
    Rectangle(12.0, 10.0) => { "Rectangle": [12.0, 10.0] }  
    

    of course there could be a setting to choose to write out the property names when provided

    Circle(20.0) => { "Circle": { "radius": 20.0 } }
    Rectangle(12.0, 10.0) => { "Rectangle": { "width": 12.0, "height": 10.0 } }
    
  7. bartelink commented on Jul 15, 2021

    @bartelink

    I agree [with the OP] that there cannot and should not be a default canonical implementation; As FSharp.SystemTextJson and the default encoding provided by Newtonsoft.Json >=6 illustrate, there are many choices for how to represent things, and there is no reasonable default IMO.

    I tend to take view that individual converters that do easy to describe things are the way to go. Thus I would ideally like to see the following in the box:

    1. TypeSafeEnumConverter: example unoptimized impl
    2. UnionConverter: IME the format you proposed in the OP ({ "discriminator": "value", <named fields from case as for a class/record, same for cases in tupled form> }) is common in the wild, and IME there are established implementations on the JVM, Swift and JS which work with that rendering format). See FsCodec UnionConverter impl. (There's also an OSS equivalent of this converter for Newtonsoft.Json that implements this scheme)
    3. I don't believe special case handling of single-case unions is something that should be supported
  8. added
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on Jul 15, 2021
  9. bartelink commented on Jul 15, 2021

    @bartelink

    Rectangle(12.0, 10.0) => { "Rectangle": [12.0, 10.0]}

    While Newtonsoft.Json does this out of the box, I'd be very much against having this representation be a default. Furthermore, I think its a harmful thing to even put in the box in the first place; let me try to justify this:

    • It's not just a private roundtrippable rendering format - its best assumed to be going into a database that will a) outlive my code b) will need to be parsed by other systems in other languages
    • because JSON does not define a tuple concept, it's less likely to be covered in any given library on the other side.
    • Arrays with mixed types is not something that should be introduced lightly into a rendering format
    • In general, if I'm looking to render or parse as JSON, there needs to be a good story wrt versioning of type being serialized/deserialized
    • versioning concerns in my code:
      • reordering fields in a tuple opens up a troubleshooting nightmare.
      • if I want to move from a 2-tuple to a record with three fields, there is no smooth transition possible
      • if I want to introduce a new optional, field, it has to go at the end
      • etc. There's likely plenty best practice in this area; this ebook covers many real world cases in depth

    In order to guard against things that IME happen in the wild, I'd propose the following rules:

    • unnamed tuple case body fields should be rejected (i.e. no Item fields)
    • by default, unions should not convert without opting in by either registering a converter or tagging the type with a converter configuration

    Point => { "Point": [] } | "Point"

    Again, IME, maintaining symmetry is valuable for nullary cases too. The main reason for this being that any consumer will be able to adapt to me adding a field to the case payload if it's rendered as { "shape": "point"}; one is simply adding a field to an object, versus transitioning from a string to an object.

    I'd also mention another reason a canonical default handing is likely to run aground even if it was to be defined:

    • Unmappable Cases: I've encountered at least the following needs when case names don't map to a case in the union
      1. Fail fast - throw an exception
      2. Have a catch-all case that hands you the body as a JObject or equivalent (see UnionConverter Unknown case support)
      3. Silently ignore
  10. jhf commented on Jul 23, 2021

    @jhf

    I agree with @bartelink that the compatibility of json encoded data can be a minefield, that requires attention when writing migrations, and when making API changes.

    I still think that having a default canonical implementation provides a good user experience. I think it is too early to force the programmer understand and choose between serialisation strategies just because they wish to turn information into JSON.

    Rather, when somebody has durable data, or needs to concern themselves with wire compatibility, then the way things are serialised may get important. In my case, when I upgrade durable data migrations, I transform the data, using the default canonical implementation, but I map from one type to another type, and write again using the default canonical implementation.
    For exposed API REST/json endpoints I either make sure it is a wire compatible change, or I make a new endpoint (probably with a version number).

    If there is a large number of types involved, then the burden of having to specify a per type serialisation strategy, as a lot of extra work. Surely, yes, it will be more optimised, and possible upgrade proof, but since I'm writing migrations and making new endpoints, that point is moot, for me. Thus I'm forced to do more work, without any apparent benefit.

    By having a default canonical implementation, and allowing overrides, it is possible to use json with a minimum amount of effort, and allow freedom when it has benefit.

    By the way, what would be the developer experience when using Type Providers, such as https://fsprojects.github.io/SQLProvider/ when there is no default canonical implementation?

    From my perspective, having a default canonical implementation, allows usage without con
    @bartelink

  11. removed
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on Nov 16, 2021
  12. 15 remaining items

  13. modified the milestones: 7.0.0, Future on May 5, 2022
  14. removed their assignment
    on Oct 3, 2022
  15. KenBonny commented on Feb 14, 2024

    @KenBonny

    Is this still being investigated and worked on? As I'm quite interested in seeing STJ support F# DU's.

  16. eiriktsarpalis commented on Feb 14, 2024

    @eiriktsarpalis
    MemberAuthor

    It is not being worked on currently. We will update this issue as soon as something changes.

  17. YkTru commented on Apr 2, 2024

    @YkTru

    @eiriktsarpalis

    1- Do you think it will ever be possible? I mean, should we keep hope or do all by hand?

    2- While waiting: Would you recommend Wlaschin DTO approach?

    2a- Else what would be your recommend, preferred approach in most cases?

    Thank you

  18. bartelink commented on Apr 2, 2024

    @bartelink

    @YkTru why do it by hand when there are two perfectly viable answers to 2a:

  19. eiriktsarpalis commented on Apr 2, 2024

    @eiriktsarpalis
    MemberAuthor

    Using a third-party option is perfectly fine. One thing to note about any custom converter is that it necessarily loses the ability to do streaming serialization (only because streaming converters are internal for now).

    In other words, attempting to serialize something large like

    type MyUnion = | Values of int []
    
    JsonSerializer.SerializeAsync(stream, Values [1 .. 1_000_000])

    would necessarily result in the entire payload being buffered by the serializer. That shouldn't matter much as long as you're restricted to small-ish values.

  20. rynoV commented on Apr 11, 2025

    @rynoV

    It seems like using a third-party option is the current recommendation for F# discriminated unions with STJ, so I wanted to bring up a potential area for improvement on the first-party side to make that work better:

    Problem

    When passing a discriminated union value with object type to JsonSerializer.Serialize, JsonConverter attributes (e.g. UnionConverter from FsCodec) on F# discriminated unions won't be resolved, and it will fail with System.NotSupportedException: F# discriminated union serialization is not supported.

    Affected areas

    This situation occurs unavoidably in ASP.NET (see dotnet/aspnetcore#51481 which was closed deferring to this issue) and SignalR.

    Cause

    The issue seems to be that GetType is used to resolve the converter for the DU value, and for a DU instantiation, GetType returns the internal generated subclass for that case, and so the JsonConverter attribute registered on the base class is not picked up.

    Potential fix

    It seems like it would be possible to fix this in STJ by adding a special case for F# discriminated union instances to resolve their type info using the base class. It doesn't seem like resolving type info for the generated subclasses makes sense because the user has no control over these (also meaning it shouldn't be a breaking change?).

    Related issue in FsCodec which discusses a workaround: jet/FsCodec#128

  21. kerams commented on May 9, 2025

    @kerams

    Users would still be able to use available custom converters

    The problem with this is that while FSTJ JsonSerializerOptions with the source generated serialization context assigned to the TypeInfoResolver property appears to work with JsonSerializerIsReflectionEnabledByDefault disabled, reflection is still used under the hood and there's no AOT-compatiblity, no?

    error IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.
    

    would suggest that.

  22. added
    in-prThere is an active PR which will close this issue when it is merged
    on Mar 16, 2026
  23. added a commit that references this issue on Mar 27, 2026
    3e90285
  24. locked and limited conversation to collaborators on Apr 27, 2026
  25. added a commit that references this issue on Jul 15, 2026
    4b5e9c5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Cost:MWork that requires one engineer up to 2 weeksPriority:3Work that is nice to haveTeam:LibrariesUser StoryA single user-facing feature. Can be grouped under an epic.api-needs-workAPI needs work before it is approved, it is NOT ready for implementationarea-System.Text.Jsonin-prThere is an active PR which will close this issue when it is merged

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions