Skip to content

System.Text.Json: [JsonNumberHandling] on a union type is not applied to its cases #133668

Description

@robertodalmonte

Description

JsonSerializerDefaults.Web sets NumberHandling = AllowReadingFromString. Under that setting, the int case of union IntOrString(int, string) also claims the JSON String token, so every string is rejected as ambiguous, including strings that are not numeric ("hello"). The same inputs deserialize fine with JsonSerializerOptions.Default.

This matters because the Web defaults are what ASP.NET Core (Minimal APIs and MVC) and System.Net.Http.Json use. The IntOrString example from the C# docs and the Unions and closed hierarchies in ASP.NET Core post therefore cannot be used as a request body in a default ASP.NET Core app.

Reproduction

using System.Text.Json;
using System.Text.Json.Serialization;

foreach (var (name, o) in new[] { ("Default", JsonSerializerOptions.Default), ("Web", JsonSerializerOptions.Web) })
    foreach (var json in new[] { "42", "\"hello\"", "\"42\"" })
    {
        try { Console.WriteLine($"{name,-7} {json,-8} => {JsonSerializer.Deserialize<IntOrString>(json, o).Value?.GetType().Name}"); }
        catch (Exception e) { Console.WriteLine($"{name,-7} {json,-8} => {e.GetType().Name}: {e.Message}"); }
    }

try { JsonSerializer.Deserialize<IntOrStringStrict>("\"hello\"", JsonSerializerOptions.Web); }
catch (Exception e) { Console.WriteLine($"[JsonNumberHandling(Strict)] on the union => {e.GetType().Name}"); }

public union IntOrString(int, string);

[JsonNumberHandling(JsonNumberHandling.Strict)]
public union IntOrStringStrict(int, string);

Output:

Default 42       => Int32
Default "hello"  => String
Default "42"     => String
Web     42       => Int32
Web     "hello"  => JsonException: JSON value type 'String' is ambiguous for union type 'IntOrString' because multiple case types can use this value type. Specify a custom type classifier to support deserialization. Path: $ | LineNumber: 0 | BytePositionInLine: 7.
Web     "42"     => JsonException: JSON value type 'String' is ambiguous for union type 'IntOrString' …
[JsonNumberHandling(Strict)] on the union => JsonException

Expected behavior

One of the following. I have listed them in order of preference.

  1. An exact token-kind match wins. A String token binds to the string case when the union has one. Number-from-string coercion applies only when no case accepts the token natively. That would make Web agree with Default for all three inputs above.
  2. [JsonNumberHandling] on the union type applies to its cases. Today it has no effect, so there is no per-type opt-out; the only fixes are app-wide options or a custom classifier.
  3. If ambiguity here is intentional, document it. Unions whose cases are distinct JSON value kinds under Default stop being distinct under Web, so (int, string) needs a classifier in any ASP.NET Core app. Verify Minimal API unions support in RDF and RDG as endpoint parameters and return types aspnetcore#66951 appears to treat "ambiguous primitive cases without classifier → 400" as expected, but the docs and the blog post present IntOrString without that caveat.

Impact observed on RC 1

  • A Minimal API or MVC [ApiController] body of type IntOrString returns 400 for any string.
  • HttpClient.GetFromJsonAsync<IntOrString> throws the same JsonException on a string payload.
  • Microsoft.AspNetCore.OpenApi publishes the int arm as
    {"type":["integer","string"],"pattern":"^-?(?:0|[1-9]\\d*)$","format":"int32"} next to
    {"type":"string"}. The document therefore advertises string inputs that the server rejects.
  • Current workarounds are NumberHandling = Strict on the host's options (which changes number
    parsing for the whole app, and must be set separately for Minimal APIs and MVC), or a custom
    JsonTypeClassifierFactory that maps JsonTokenType.String to typeof(string).

Configuration

  • .NET SDK 11.0.100-rc.1.26425.128, Microsoft.NETCore.App 11.0.0-rc.1.26425.128
  • Windows 11 x64
  • No LangVersion override (net11.0 defaults to C# 15)

Repro as a runnable project: https://github.com/robertodalmonte/csharp-unions-probed/tree/main/probes/QRepro1 (dotnet run; the last line of its output is the [JsonNumberHandling] case this issue now tracks).

Activity

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

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions