dotnet / dotnet/runtime

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

Open
#133,668 4 comments 1 reaction 2 assignees Claimed by @eiriktsarpalis View on GitHub
area-System.Text.Json bug
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.