Skip to content

Minimal API: nullable value-type body parameter (T?) yields different handler input on empty body between runtime and source generator #545

@DeagleGross

Description

@DeagleGross

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.csTryResolveBodyAsync<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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions