Summary
For a nullable value-type body parameter (Nullable<T> where T is a struct) on a Minimal API endpoint, the runtime (RequestDelegateFactory) and source-generator paths produce different handler input values when the request body is empty.
- Runtime path → handler receives
new T?(default(T)) — i.e. HasValue == true wrapping a zeroed struct.
- Source generator → handler receives
null (HasValue == false).
Both paths return 200 OK, so the status code is consistent. The discrepancy is purely in what value is observed by the handler.
This is not union-specific — it reproduces with any value-type body parameter (including ordinary structs). Discovered while building union body-binding test coverage (parent: dotnet#64599), but the behavior gap predates unions.
Repro
public record struct BodyStruct(int A, string? B);
app.MapPost("/nullable-struct", (BodyStruct? body) =>
body.HasValue ? "has-value" : "null");
Send an empty-body request:
POST /nullable-struct
Content-Type: application/json
Content-Length: 0
Observed
| Path |
Status |
Response body |
| Runtime (RDF) |
200 |
"has-value" |
| Source generator |
200 |
"null" |
The runtime path means handlers that check body.HasValue to detect "no body was supplied" silently produce wrong results — HasValue == true despite no body being present.
Expected
A single consistent behavior across both paths. Personal preference: the source-generator behavior (null) is more intuitive — HasValue should faithfully reflect whether the caller actually supplied a body. But either choice is defensible; what matters is they match.
If runtime's behavior is intentional (e.g. matching how value-type defaults are produced elsewhere), the source generator should be brought in line so user code doesn't break when switching between the two paths.
Why this matters
- The most natural use of
BodyStruct? in a handler is exactly the "did the caller send a body?" check. Today that check is unreliable on the runtime path.
- Code that works correctly under one path can silently misbehave under the other, which is a footgun for users who turn on the source generator (or vice versa).
- It compounds with the related
[FromBody(EmptyBodyBehavior=Allow)] BodyStruct case where the runtime path correctly assigns default(T) — the nullable variant should follow the same model (assign a meaningful absence indicator).
Where to look
Runtime path — the body resolver in RequestDelegateFactory:
src/Http/Http.Extensions/src/RequestDelegateFactory.cs — locate the body-binding emitter and check what it produces for an empty body when the target type is Nullable<T>.
Source generator path:
src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/RequestDelegateGeneratorSources.cs — TryResolveBodyAsync<T> returns default(T) for T = BodyStruct?, which is null. This is the source of the "null" result.
The fix likely needs to happen on one side only; reconcile to whichever behavior is decided.
Repro test (optional, for reference)
[Fact]
public async Task NullableStructBody_EmptyBody_Divergence()
{
var source = """
app.MapPost("/", (BodyStruct? b) => b.HasValue ? "has-value" : "null");
""";
var (_, compilation) = await RunGeneratorAsync(source);
var endpoint = GetEndpointsFromCompilation(compilation).OfType<RouteEndpoint>().Single();
var ctx = CreateEmptyJsonRequest();
await endpoint.RequestDelegate(ctx);
Assert.Equal(200, ctx.Response.StatusCode);
// Runtime returns "has-value", source generator returns "null".
var expected = IsGeneratorEnabled ? "null" : "has-value";
await VerifyResponseBodyAsync(ctx, expected);
}
Environment
main (.NET 11 work), but the divergent code paths are unchanged on release/10.0 and earlier — likely reproduces on every shipped version of the source generator.
- Repro doesn't require unions.
Related
Summary
For a nullable value-type body parameter (
Nullable<T>whereTis a struct) on a Minimal API endpoint, the runtime (RequestDelegateFactory) and source-generator paths produce different handler input values when the request body is empty.new T?(default(T))— i.e.HasValue == truewrapping a zeroed struct.null(HasValue == false).Both paths return
200 OK, so the status code is consistent. The discrepancy is purely in what value is observed by the handler.This is not union-specific — it reproduces with any value-type body parameter (including ordinary structs). Discovered while building union body-binding test coverage (parent: dotnet#64599), but the behavior gap predates unions.
Repro
Send an empty-body request:
Observed
"has-value""null"The runtime path means handlers that check
body.HasValueto detect "no body was supplied" silently produce wrong results —HasValue == truedespite no body being present.Expected
A single consistent behavior across both paths. Personal preference: the source-generator behavior (
null) is more intuitive —HasValueshould faithfully reflect whether the caller actually supplied a body. But either choice is defensible; what matters is they match.If runtime's behavior is intentional (e.g. matching how value-type defaults are produced elsewhere), the source generator should be brought in line so user code doesn't break when switching between the two paths.
Why this matters
BodyStruct?in a handler is exactly the "did the caller send a body?" check. Today that check is unreliable on the runtime path.[FromBody(EmptyBodyBehavior=Allow)] BodyStructcase where the runtime path correctly assignsdefault(T)— the nullable variant should follow the same model (assign a meaningful absence indicator).Where to look
Runtime path — the body resolver in
RequestDelegateFactory:src/Http/Http.Extensions/src/RequestDelegateFactory.cs— locate the body-binding emitter and check what it produces for an empty body when the target type isNullable<T>.Source generator path:
src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/RequestDelegateGeneratorSources.cs—TryResolveBodyAsync<T>returnsdefault(T)forT = BodyStruct?, which isnull. This is the source of the "null" result.The fix likely needs to happen on one side only; reconcile to whichever behavior is decided.
Repro test (optional, for reference)
Environment
main(.NET 11 work), but the divergent code paths are unchanged onrelease/10.0and earlier — likely reproduces on every shipped version of the source generator.Related
[FromBody(EmptyBodyBehavior=Allow)]dropped when delegate signatures collide); also non-union, surfaced from the same investigation.