System.Text.Json: `[JsonNumberHandling]` on a union type is not applied to its cases
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### 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
```csharp
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(json, o).Value?.GetType().Name}"); }
catch (Exception e) { Console.WriteLine($"{name,-7} {json,-8} => {e.GetType().Name}: {e.Message}"); }
}
try { JsonSerializer.Deserialize("\"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. dotnet/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` 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).
Contributor guide
Assessment
This issue has not been assessed yet.