Skip to content

Add System.Text.Json support for F# discriminated unions#125610

Draft
eiriktsarpalis wants to merge 4 commits intomainfrom
feature/fsharp-unions
Draft

Add System.Text.Json support for F# discriminated unions#125610
eiriktsarpalis wants to merge 4 commits intomainfrom
feature/fsharp-unions

Conversation

@eiriktsarpalis
Copy link
Member

System.Text.Json: Add support for F# discriminated unions

Fixes #55744

Summary

Adds JSON serialization and deserialization support for F# discriminated unions to System.Text.Json. This is the last remaining F# type that was not supported by STJ.

Serialization Format

Fieldless union cases serialize as JSON strings. Cases with fields serialize as JSON objects with a $type type discriminator property:

type Shape =
    | Point
    | Circle of radius: float
    | Rectangle of height: float * length: float
F# Value JSON
Point "Point"
Circle 3.14 {"$type":"Circle","radius":3.14}
Rectangle(10.0, 20.0) {"$type":"Rectangle","height":10,"length":20}

Both forms are accepted on deserialization: "Point" and {"$type":"Point"} are equivalent for fieldless cases.

Features

  • Class and struct unions are both supported.
  • Recursive unions (e.g. tree types) work via lazy converter resolution.
  • PropertyNamingPolicy applies to both case discriminator names and field names.
  • PropertyNameCaseInsensitive applies to both case names and field names during deserialization.
  • JsonPropertyNameAttribute on union cases overrides the discriminator name (takes precedence over naming policy).
  • Custom discriminator property name via [<JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")>] on the union type.
  • AllowOutOfOrderMetadataProperties — the $type discriminator does not need to be the first property.
  • RespectRequiredConstructorParameters — when enabled, all union case fields are treated as required (matching F# record behavior). When disabled, missing fields default to default(T).
  • String form tolerance"Circle" is accepted for cases with fields when RespectRequiredConstructorParameters is off (constructs with all-default field values).

Examples

open System.Text.Json
open System.Text.Json.Serialization

// Basic serialization
let json = JsonSerializer.Serialize(Circle 3.14)
// -> {"$type":"Circle","radius":3.14}

let shape = JsonSerializer.Deserialize<Shape>(json)
// -> Circle 3.14

// With naming policy
let options = JsonSerializerOptions(PropertyNamingPolicy = JsonNamingPolicy.CamelCase)
let json2 = JsonSerializer.Serialize(Circle 3.14, options)
// -> {"$type":"circle","radius":3.14}

// Custom discriminator
[<JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")>]
type MyUnion = Alpha | Beta of x: int

let json3 = JsonSerializer.Serialize(Beta 42)
// -> {"kind":"Beta","x":42}

// Out-of-order discriminator
let options2 = JsonSerializerOptions(AllowOutOfOrderMetadataProperties = true)
let result = JsonSerializer.Deserialize<Shape>("""{"radius":3.14,"$type":"Circle"}""", options2)
// -> Circle 3.14

Reflection-only

This feature works exclusively with the reflection-based serializer. It does not work with:

  • C# source generators (JsonSourceGenerationOptions / JsonSerializerContext) — the source generator does not emit metadata for F# union types. Attempting to include an F# union type in a source-generated context will produce a compile-time error or fall back to the default object serialization.
  • Native AOT — because the implementation relies on reflection (FSharp.Core reflection APIs for reading union case metadata, Func<object[], object> constructors, and Func<object, object[]> field readers), it requires the RequiresUnreferencedCode and RequiresDynamicCode code paths. F# unions will not serialize correctly in trimmed or AOT-compiled applications.

Implementation

  • FSharpUnionConverter<T> — a JsonConverter<T> with ConverterStrategy.Value that handles both string and object JSON forms.
  • FSharpCoreReflectionProxy — extended with union case metadata extraction (FSharpUnionCaseInfo, tag readers, constructors, field readers).
  • FSharpTypeConverterFactory — updated to detect union types and create the converter, reading [JsonPolymorphic] for custom discriminator names.
  • Field count per union case is limited to 64 (bitmask-based required field tracking).
  • No new public API surface.

Implements JSON serialization and deserialization for F# discriminated
unions using a custom JsonConverter. Fieldless cases serialize as JSON
strings; cases with fields serialize as JSON objects with a type
discriminator property.

Key features:
- Fieldless cases: "Point" <-> Point
- Cases with fields: {"":"Circle","radius":3.14} <-> Circle 3.14
- Supports both class and struct unions, recursive unions, and option fields
- Honors PropertyNamingPolicy, PropertyNameCaseInsensitive, and
  JsonPropertyNameAttribute on union cases
- Custom discriminator property name via [JsonPolymorphic] attribute
- AllowOutOfOrderMetadataProperties support via checkpoint/restore
- RespectRequiredConstructorParameters support with bitmask validation
- Missing fields default to default(T) when not required
- String form tolerated for cases with fields when not required
- Lazy converter resolution for recursive union types
- Reflection-only (source generators not supported)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

eiriktsarpalis and others added 2 commits March 16, 2026 16:11
…with pragma

Replace [UnconditionalSuppressMessage] attributes for IL2075 and IL2055 with
#pragma warning disable directives. ILLink no longer emits these warnings
(triggering IL2121 unused-suppression errors), but the Roslyn trim analyzer
still does, so pragma directives are the correct suppression mechanism.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rd2.0/netfx

RuntimeHelpers.GetUninitializedObject is not available on netstandard2.0 or
net462. Use FormatterServices.GetUninitializedObject on those targets instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 16, 2026 14:50
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.


You can also share your feedback on Copilot code review. Take the survey.

- Detect duplicate field names in BuildFieldIndexMap and throw (#1)
- Honor JsonPropertyNameAttribute on union case field PropertyInfo (#2)
- Validate discriminator name doesn't conflict with field names (#3)
- Validate token type is String before reading discriminator value (#4)
- Validate TypeDiscriminatorPropertyName against reserved names (#5)
- Use Delegate.CreateDelegate in ConvertFSharpFunc for performance (#6)
- Detect case-insensitive discriminator name collisions and throw (#7)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
eiriktsarpalis

This comment was marked as resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants