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:

Program.cs
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:

Program.cs
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:

Example.cs
// 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.

Program.cs
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:

  1. Compile-time safety — the compiler rejects any branch that returns a type not listed in the union. Return a BadRequest without declaring it? Build error.
  2. Automatic OpenAPI metadata — the framework reads the generic type arguments and generates a spec with a 200 response carrying the Order schema and a 404 response with no body. No Produces calls needed.
  3. 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:

Endpoints/OrderEndpoints.cs
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:

Tests/OrderEndpointTests.cs
[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:

Example.cs
// 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:

GlobalUsings.cs
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:

  1. Replace Results.Ok(x) with TypedResults.Ok(x) (and similarly for other methods).
  2. Add the explicit return type Task<Results<T1, T2>> to the endpoint delegate.
  3. Remove any .Produces<T>() or .Produces(statusCode) calls — they are now redundant.

The before and after:

Example.cs
// 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:

Example.cs
// 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