TypedResults and Results<T1, T2>

Return TypedResults and Results<T1, T2> unions so the compiler, the OpenAPI document and unit tests all know every response a handler can produce.

Syntaxapp.MapGet("/orders/{id:int}", Results<Ok<OrderDto>, NotFound> (int id) => Find(id) is { } o ? TypedResults.Ok(o) : TypedResults.NotFound());

There are two factories for responses. Results.Ok(order) returns IResult: the runtime knows it is a 200 with a body, but the method signature does not. TypedResults.Ok(order) returns Ok<Order>, a concrete type that implements IResult and also describes itself as metadata. Both produce the same bytes on the wire; the difference is what the compiler, OpenAPI and tests can see.

Union return types

A handler usually has more than one outcome. Results<Ok<OrderDto>, NotFound> is a union of up to six result types. Each member converts implicitly to the union, so the handler just returns TypedResults.Ok(dto) or TypedResults.NotFound(). For a lambda you must declare the return type, because Ok<OrderDto> and NotFound have no common type to infer:

app.MapGet("/orders/{id:int}", async Task<Results<Ok<OrderDto>, NotFound>> (int id, ShopDb db) => ...);

Returning a result type that is not in the union is a compile error, which is the point: the declared contract and the code cannot drift.

Why it matters

Results / IResultTypedResults / Results<...>
OpenAPI responsesNone until you add .Produces<T>() by handGenerated: each type implements IEndpointMetadataProvider
Unit testsCast or execute against a fake HttpContextAssert.IsType<Ok<OrderDto>>(result.Result), then read Value
Compile-time checksAny IResult goesOnly the declared outcomes

Choose TypedResults for every new endpoint. Results remains useful in a helper that genuinely returns many shapes, and the two can be mixed.

The common results

  • Ok<T> 200 and Created<T> 201 with a Location header (CreatedAtRoute builds it from an endpoint name).
  • NoContent 204 for successful updates and deletes, Accepted 202 for queued work.
  • NotFound 404, Conflict 409, UnprocessableEntity 422.
  • ValidationProblem 400 with an errors dictionary, and ProblemHttpResult from TypedResults.Problem(...) for RFC 9457 error bodies.

Failure modes

The declared type parameter does not limit what is serialized. Ok<Payment> holding a CardPayment writes the runtime type, so subclass fields such as card details appear in the JSON. Return dedicated response DTOs, never entities. Also, an error that should be a ProblemDetails body is easy to return as a bare NotFound(); decide per API which errors carry a body and add those types to the union.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Declare GET /orders/{id} as Results<Ok<OrderDto>, NotFound> so the OpenAPI document lists both 200 and 404 without hand-written metadata.
  • Return 201 with a Location header from CreatedAtRoute after creating an order, and 409 when the purchase order number was already submitted.
  • Unit test an invoice handler by asserting Assert.IsType<NotFound>(result.Result) without starting a web server.

More examples

Unit testing a union result

<code>Results&lt;T1, T2&gt;</code> exposes the actual result through <code>Result</code>, and each typed result exposes <code>Value</code> and <code>StatusCode</code>. The test needs no server, no JSON parsing and no <code>HttpContext</code>, so it runs in microseconds. With <code>Results.Ok(...)</code> the handler's declared return type is <code>IResult</code>. The test can still <code>Assert.IsType&lt;Ok&lt;InvoiceDto&gt;&gt;(result)</code>, because <code>Results.Ok</code> returns the same public <code>Ok&lt;T&gt;</code> at runtime. What it loses is the compile-time list of outcomes and the OpenAPI metadata the union provides.

Example · csharp
using Microsoft.AspNetCore.Http.HttpResults;
using Xunit;

public class InvoiceEndpointTests
{
    [Fact]
    public void Returns_404_for_an_unknown_invoice()
    {
        var result = InvoiceEndpoints.GetById("INV-0000", new InvoiceStore());

        Assert.IsType<NotFound>(result.Result);
    }

    [Fact]
    public void Returns_the_invoice_with_its_total()
    {
        var result = InvoiceEndpoints.GetById("INV-2026-0042", new InvoiceStore());

        var ok = Assert.IsType<Ok<InvoiceDto>>(result.Result);
        Assert.Equal(1250.00m, ok.Value!.Total);
        Assert.Equal(200, ok.StatusCode);
    }
}

public static class InvoiceEndpoints
{
    public static Results<Ok<InvoiceDto>, NotFound> GetById(string number, InvoiceStore store) =>
        store.Find(number) is { } invoice ? TypedResults.Ok(invoice) : TypedResults.NotFound();
}

public sealed class InvoiceStore
{
    public InvoiceDto? Find(string number) =>
        number == "INV-2026-0042" ? new InvoiceDto(number, "C-1001", 1250.00m) : null;
}

public record InvoiceDto(string Number, string CustomerId, decimal Total);

Pitfall: the runtime type is what gets serialized

The generic argument only shapes the OpenAPI schema; the JSON writer serializes the object's runtime type, so <code>/leaky</code> returns <code>cardFingerprint</code> even though the documented schema does not mention it. This is how internal fields escape when handlers return entities or base types. A response DTO built by an explicit mapping function makes the wire contract a deliberate list of fields.

Example · csharp
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Leaks: Ok<Payment> still serializes the CardPayment instance, including CardFingerprint.
app.MapGet("/payments/{id:int}/leaky", (int id) =>
    TypedResults.Ok<Payment>(new CardPayment(id, 49.90m, "fp_live_<redacted>", "4242")));

// Safe: map to a response contract that contains only what the client may see.
app.MapGet("/payments/{id:int}", Ok<PaymentResponse> (int id) =>
{
    Payment payment = new CardPayment(id, 49.90m, "fp_live_<redacted>", "4242");
    return TypedResults.Ok(PaymentResponse.From(payment));
});

app.Run();

public record Payment(int Id, decimal Amount);
public record CardPayment(int Id, decimal Amount, string CardFingerprint, string Last4) : Payment(Id, Amount);

public record PaymentResponse(int Id, decimal Amount, string Method, string? Last4)
{
    public static PaymentResponse From(Payment payment) => payment switch
    {
        CardPayment card => new(card.Id, card.Amount, "card", card.Last4),
        _ => new(payment.Id, payment.Amount, "other", null)
    };
}

IResult endpoints need manual metadata

<code>Results.Ok</code> (the static factory) and <code>Results&lt;T1, T2&gt;</code> (the generic union in <code>Microsoft.AspNetCore.Http.HttpResults</code>) differ by arity, so both names work in one file. Hand-written <code>Produces</code> calls drift: someone adds a 409 branch and forgets the metadata, and the published contract is wrong with nothing failing. The union version cannot drift because returning an undeclared result does not compile. <code>ProblemHttpResult</code> covers any status, so document the specific codes with <code>ProducesProblem</code> as well if clients rely on them.

Example · csharp
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();

// Results.* returns IResult, so the response types must be declared by hand for OpenAPI.
app.MapGet("/customers/{id:int}", (int id) =>
        id == 42 ? Results.Ok(new CustomerDto(42, "Northwind Traders")) : Results.Problem(statusCode: 404, title: "Customer not found"))
    .Produces<CustomerDto>(StatusCodes.Status200OK)
    .ProducesProblem(StatusCodes.Status404NotFound);

// Equivalent with typed results: the union is the documentation.
app.MapGet("/v2/customers/{id:int}", Results<Ok<CustomerDto>, ProblemHttpResult> (int id) =>
    id == 42
        ? TypedResults.Ok(new CustomerDto(42, "Northwind Traders"))
        : TypedResults.Problem(statusCode: 404, title: "Customer not found"));

app.Run();

public record CustomerDto(int Id, string Name);

Discussion

  • Be the first to comment on this lesson.