You have a Minimal API endpoint that returns either a product or a 404. You write Results.Ok(product) and Results.NotFound(), ship it, then check your generated OpenAPI spec. The 200 response shows application/json with no schema. Your client SDK generator produces a method that returns object. Your frontend team sends you a message asking what the endpoint actually returns.
This is the gap that TypedResults fills. It has been available since .NET 7, but a surprising number of codebases still use the untyped Results class — or worse, return raw objects and rely on Produces<T>() calls to describe what the endpoint does. If you are building Minimal APIs in .NET 10, TypedResults combined with the Results<T1, T2> union pattern should be your default.
The problem with Results
The Results static class provides factory methods like Results.Ok(), Results.NotFound(), and Results.BadRequest(). Every one of them returns IResult. This compiles perfectly:
app.MapGet("/orders/{id}", async (int id, OrderDb db) =>
await db.Orders.FindAsync(id)
is Order order
? Results.Ok(order)
: Results.NotFound());
The compiler is happy because both branches return IResult. The OpenAPI generator, however, is not. It sees an endpoint that returns IResult and has no way to determine that a successful response carries an Order or that the 404 has no body. You end up needing to bolt on metadata manually:
app.MapGet("/orders/{id}", async (int id, OrderDb db) =>
await db.Orders.FindAsync(id)
is Order order
? Results.Ok(order)
: Results.NotFound())
.Produces<Order>(200)
.Produces(404);
This works, but the metadata is completely detached from the code that actually produces the response. Change the return type and forget to update the Produces calls? Your OpenAPI spec silently drifts out of sync with reality. No compiler warning, no build error — just a broken contract.
Enter TypedResults
TypedResults is the typed equivalent of Results. The difference is in what each method returns. Results.Ok(order) returns IResult. TypedResults.Ok(order) returns Ok<Order> — a concrete generic type that implements IResult and carries response metadata in its type signature.
This means the OpenAPI infrastructure can inspect the return type at build time and generate accurate schemas without any manual Produces calls.
The trade-off is that you can no longer rely on implicit conversion to IResult when an endpoint returns multiple result types. This will not compile:
// Bad — does not compile
app.MapGet("/orders/{id}", async (int id, OrderDb db) =>
await db.Orders.FindAsync(id)
is Order order
? TypedResults.Ok(order) // Ok<Order>
: TypedResults.NotFound()); // NotFound — different type
The compiler cannot unify Ok<Order> and NotFound into a single return type. This is where the Results<T1, T2> union pattern comes in.
The Results<T1, T2> union pattern
ASP.NET Core provides generic union types from Results<T1, T2> up to Results<T1, T2, T3, T4, T5, T6>. These act as discriminated unions — the return value is exactly one of the specified types, and the compiler enforces it.
app.MapGet("/orders/{id}",
async Task<Results<Ok<Order>, NotFound>> (int id, OrderDb db) =>
await db.Orders.FindAsync(id)
is Order order
? TypedResults.Ok(order)
: TypedResults.NotFound());
The explicit return type Results<Ok<Order>, NotFound> does three things simultaneously:
- Compile-time safety — the compiler rejects any branch that returns a type not listed in the union. Return a
BadRequestwithout declaring it? Build error. - Automatic OpenAPI metadata — the framework reads the generic type arguments and generates a spec with a 200 response carrying the
Orderschema and a 404 response with no body. NoProducescalls needed. - Self-documenting signatures — anyone reading the method signature knows exactly what this endpoint can return. The contract is in the code, not in attributes scattered above it.
A realistic example
Here is a more complete endpoint that handles creation with validation:
public static class OrderEndpoints
{
public static void MapOrderEndpoints(this WebApplication app)
{
var group = app.MapGroup("/orders").WithTags("Orders");
group.MapGet("/{id}",
async Task<Results<Ok<OrderResponse>, NotFound>> (int id, OrderDb db) =>
await db.Orders.FindAsync(id) is Order order
? TypedResults.Ok(order.ToResponse())
: TypedResults.NotFound());
group.MapPost("/",
async Task<Results<Created<OrderResponse>, ValidationProblem>>
(CreateOrderRequest request, OrderDb db) =>
{
var errors = Validate(request);
if (errors.Count > 0)
return TypedResults.ValidationProblem(errors);
var order = new Order
{
CustomerId = request.CustomerId,
Product = request.Product,
Quantity = request.Quantity,
CreatedAt = DateTime.UtcNow
};
db.Orders.Add(order);
await db.SaveChangesAsync();
return TypedResults.Created(
$"/orders/{order.Id}",
order.ToResponse());
});
group.MapDelete("/{id}",
async Task<Results<NoContent, NotFound>> (int id, OrderDb db) =>
{
var order = await db.Orders.FindAsync(id);
if (order is null)
return TypedResults.NotFound();
db.Orders.Remove(order);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
}
private static Dictionary<string, string[]> Validate(CreateOrderRequest request)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(request.Product))
errors["Product"] = ["Product name is required."];
if (request.Quantity <= 0)
errors["Quantity"] = ["Quantity must be greater than zero."];
return errors;
}
}
Every endpoint declares its full response contract in the return type. The generated OpenAPI spec will correctly describe each possible status code and response body without a single Produces call or [ProducesResponseType] attribute.
Unit testing becomes straightforward
One of the less obvious benefits of TypedResults is how much it simplifies unit tests. With IResult, testing typically involves calling ExecuteAsync with a mock HttpContext and inspecting the response status code and body. With typed results, you can assert directly against the concrete type:
[Fact]
public async Task GetOrder_ReturnsOk_WhenOrderExists()
{
// Arrange
var db = CreateTestDb();
var expected = new Order { Id = 1, Product = "Widget", Quantity = 5 };
db.Orders.Add(expected);
await db.SaveChangesAsync();
// Act
var result = await OrderEndpoints.GetById(1, db);
// Assert
var ok = Assert.IsType<Ok<OrderResponse>>(result.Result);
Assert.Equal("Widget", ok.Value!.Product);
Assert.Equal(5, ok.Value.Quantity);
}
[Fact]
public async Task GetOrder_ReturnsNotFound_WhenMissing()
{
var db = CreateTestDb();
var result = await OrderEndpoints.GetById(999, db);
Assert.IsType<NotFound>(result.Result);
}
No HttpContext mocking. No response body deserialisation. The Result property on Results<T1, T2> gives you the actual typed result object, and you assert against its properties directly. This makes tests faster to write, easier to read, and less brittle.
// TIP
To make endpoints individually testable like this, extract them into static methods on a class rather than defining them inline as lambdas in Program.cs. The mapping still happens in your configuration code, but the logic lives in a method you can call directly from tests.
The full TypedResults API surface
TypedResults covers all the common HTTP response patterns. Here are the ones you will reach for most often:
| Method | Return type | Status code | Use case |
|---|---|---|---|
Ok(value) |
Ok<T> |
200 | Successful retrieval |
Created(uri, value) |
Created<T> |
201 | Resource created |
CreatedAtRoute(routeName, routeValues, value) |
CreatedAtRoute<T> |
201 | Resource created with route-based URI |
Accepted(uri, value) |
Accepted<T> |
202 | Accepted for processing |
NoContent() |
NoContent |
204 | Successful with no body |
BadRequest(value) |
BadRequest<T> |
400 | Client error |
ValidationProblem(errors) |
ValidationProblem |
400 | Validation failures (RFC 7807) |
NotFound() |
NotFound |
404 | Resource not found |
Conflict(value) |
Conflict<T> |
409 | Conflict with current state |
UnprocessableEntity(value) |
UnprocessableEntity<T> |
422 | Semantic validation failure |
Problem(detail, ...) |
ProblemHttpResult |
500 | Server error (RFC 7807) |
There are also Json<T>, Text, Bytes, Stream, File, PhysicalFile, and RedirectToRoute variants for less common scenarios. The full list is on the TypedResults class in Microsoft.AspNetCore.Http.
When the union type gets unwieldy
The union pattern works well for two to four result types. Once you reach five or six, the return type declaration starts to dominate the method signature:
// This is getting long
async Task<Results<Ok<Order>, NotFound, ValidationProblem, Conflict<string>, UnprocessableEntity>> ...
A few strategies help here:
Use a type alias. C# 12 introduced global using aliases, and they work for constructed generic types:
global using OrderGetResult = Microsoft.AspNetCore.Http.HttpResults.Results<
Microsoft.AspNetCore.Http.HttpResults.Ok<OrderResponse>,
Microsoft.AspNetCore.Http.HttpResults.NotFound>;
This keeps individual endpoint signatures clean at the cost of requiring readers to look up the alias.
Extract into an endpoint filter. If multiple endpoints share the same validation logic, move it into an IEndpointFilter so the endpoint itself only needs to handle the happy path and domain-specific errors. This naturally reduces the number of result types any single endpoint returns.
Accept the verbosity. A verbose but accurate return type is better than a terse IResult that tells you nothing. If the endpoint genuinely has six possible outcomes, that complexity exists whether you express it in the type system or not. At least with TypedResults, the compiler enforces the contract.
Migrating from Results to TypedResults
If you have existing Minimal API endpoints using Results, migration is mechanical:
- Replace
Results.Ok(x)withTypedResults.Ok(x)(and similarly for other methods). - Add the explicit return type
Task<Results<T1, T2>>to the endpoint delegate. - Remove any
.Produces<T>()or.Produces(statusCode)calls — they are now redundant.
The before and after:
// Bad — untyped, requires manual Produces metadata
app.MapGet("/products/{id}", async (int id, ProductDb db) =>
await db.Products.FindAsync(id) is Product p
? Results.Ok(p)
: Results.NotFound())
.Produces<Product>(200)
.Produces(404);
// Good — typed, self-describing
app.MapGet("/products/{id}",
async Task<Results<Ok<Product>, NotFound>> (int id, ProductDb db) =>
await db.Products.FindAsync(id) is Product p
? TypedResults.Ok(p)
: TypedResults.NotFound());
// WARNING
If you have client SDK generators or contract tests that rely on the existing OpenAPI output, verify the generated spec after migration. The metadata should be equivalent, but the structure may differ slightly — for instance, TypedResults generates response schemas from the type arguments rather than from Produces metadata, which can change property ordering in some generators.
Common pitfalls
Mixing Results and TypedResults in the same endpoint. This compiles if the return type is IResult, but you lose the benefits of type safety and automatic metadata. Pick one and stick with it — preferably TypedResults.
Forgetting the explicit return type. If you omit the Task<Results<T1, T2>> return type annotation, the compiler falls back to inferring IResult, and OpenAPI metadata disappears. The whole point is the explicit type declaration.
Using Results<T1, T2> with only one type argument. There is no Results<T> — if your endpoint only ever returns one result type, return that type directly:
// Good — single result type, no union needed
app.MapGet("/health",
() => TypedResults.Ok(new { Status = "Healthy" }));
Returning TypedResults.Ok() without a value when you mean NoContent. TypedResults.Ok() returns Ok (no generic argument) with a 200 status and an empty body. If you intend to signal success with no body, use TypedResults.NoContent() for the semantically correct 204.
Not extracting endpoint logic for testability. Inline lambdas work fine for simple cases, but they cannot be unit tested in isolation. Once an endpoint has branching logic, extract it into a named static method.
Summary
TypedResultsreplaces the untypedResultsclass with strongly typed factory methods that carry response metadata in their return types.- The
Results<T1, T2, ...>union pattern declares the full set of possible responses at the type level, giving you compile-time enforcement and automatic OpenAPI schema generation. - Unit tests can assert directly against typed result objects without mocking
HttpContextor deserialising response bodies. - Manual
.Produces<T>()calls become unnecessary — the type system handles it. - Migrate from
ResultstoTypedResultsby swapping factory methods, adding explicit return types, and removing redundant metadata calls. - For new Minimal API endpoints in .NET 10,
TypedResultswith union return types should be the default choice.