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.
app.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 / IResult | TypedResults / Results<...> | |
|---|---|---|
| OpenAPI responses | None until you add .Produces<T>() by hand | Generated: each type implements IEndpointMetadataProvider |
| Unit tests | Cast or execute against a fake HttpContext | Assert.IsType<Ok<OrderDto>>(result.Result), then read Value |
| Compile-time checks | Any IResult goes | Only 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 andCreated<T>201 with aLocationheader (CreatedAtRoutebuilds it from an endpoint name).NoContent204 for successful updates and deletes,Accepted202 for queued work.NotFound404,Conflict409,UnprocessableEntity422.ValidationProblem400 with anerrorsdictionary, andProblemHttpResultfromTypedResults.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
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<T1, T2></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<Ok<InvoiceDto>>(result)</code>, because <code>Results.Ok</code> returns the same public <code>Ok<T></code> at runtime. What it loses is the compile-time list of outcomes and the OpenAPI metadata the union provides.
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.
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<T1, T2></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.
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