Start Debugging

Typed results (Results<>) vs IResult vs IActionResult in ASP.NET Core 11

In ASP.NET Core 11, return Results<T1, TN> with TypedResults for minimal APIs and ActionResult<T> for controllers. Treat bare IResult and bare IActionResult as escape hatches: they compile for any response but describe nothing to OpenAPI, so you pay for them in hand-written ProducesResponseType attributes.

If your endpoint has one possible response, declare that one concrete type and move on. If it has several, the sharp answer in ASP.NET Core 11 is: return Results<TResult1, TResultN> with TypedResults from a minimal API, and ActionResult<T> from a controller. Both give you compile-time checking that the handler only returns what it declares, and both hand the OpenAPI generator the response metadata for free. The two interface types, bare IResult and bare IActionResult, are escape hatches: they compile no matter what you return, which is exactly why they describe nothing to the framework and force you to hand-write [ProducesResponseType] or .Produces to get an accurate spec. Everything below targets .NET 11 with Microsoft.NET.Sdk.Web and C# 14; the HttpResults types have behaved the same way since .NET 7, so the same code runs on .NET 10 GA unchanged.

The three contenders in the queue title map to two different worlds. IActionResult is the MVC controller world. IResult and its typed union Results<> are the minimal API world built on the Microsoft.AspNetCore.Http.HttpResults namespace. The wrinkle that makes this comparison worth writing is that as of .NET 7 the HttpResults types work in controllers too, so on a controller action you now have a genuine choice between the MVC result types and the minimal API ones. Picking well means understanding what each type does and does not carry.

The feature matrix

FeatureIActionResultActionResult<T>IResult (bare)Results<T1, TN>
Primary homeControllersControllersMinimal APIs + controllersMinimal APIs + controllers
Self-describes to OpenAPINoPartial (infers T)NoYes
Needs [ProducesResponseType] / .ProducesYes, liberallyFor non-T status codesYesNo
Compile-time return checkingNoNoNoYes
Content negotiation / formattersYesYesNoNo
Implicit cast from the payload typeNo (interface)Yes (T to ActionResult<T>)NoYes (each union arg)
Directly unit-testable resultCast requiredCast requiredCast requiredConcrete .Result

Read the matrix top to bottom and the pattern is clear. The two interface rows are “No” on every metadata and safety column. The two typed rows earn their verbosity by turning “No” into “Yes”. The one column where the interfaces and ActionResult<T> beat the HttpResults types is content negotiation, and that single row is the gotcha that occasionally picks for you. More on it below.

When to pick Results<> (and TypedResults)

Reach for the union whenever a minimal API endpoint can answer with more than one shape.

Here is the canonical minimal API shape:

// .NET 11, C# 14 -- Program.cs
using Microsoft.AspNetCore.Http.HttpResults;

app.MapGet("/todos/{id}", async Task<Results<Ok<Todo>, NotFound>> (int id, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(todo);
});

No .Produces, and the generated OpenAPI document lists a 200 with a Todo schema and a 404 with no body, both derived from the return type. The step-by-step conversion, the six-type ceiling, and the testing payoff are covered in depth in how to return a typed Results union from a minimal API endpoint; this post is about when to choose it over the alternatives, not how to wire it.

When to pick ActionResult

Reach for ActionResult<T> when you are writing a controller action with a primary success payload and one or more error branches.

// .NET 11, C# 14 -- ProductsController.cs
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<Product> GetById(int id)
{
    var product = _db.Products.Find(id);
    return product is null ? NotFound() : product;   // implicit cast T -> ActionResult<T>
}

The reason ActionResult<T> exists and IActionResult cannot replace it is a C# rule, not a framework decision: C# does not allow implicit cast operators on interfaces. ActionResult<T> is a concrete generic type, so it can define the implicit conversion from T that lets you write return product;. IActionResult is an interface, so it never can. That is the whole ergonomic gap between the two.

When bare IActionResult or IResult is actually correct

Neither interface is wrong, they are just narrow. Use them deliberately, not by default.

The bare IResult version in a controller looks like this, and note the attributes are back:

// .NET 11, C# 14 -- ProductsController.cs
[HttpGet("{id}")]
[ProducesResponseType<Product>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IResult GetById(int id)
{
    var product = _db.Products.Find(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
}

Every Results.* helper returns IResult, so the compiler infers IResult for both branches and never complains, and the ApiExplorer sees an interface that says nothing about status codes. That is why the two [ProducesResponseType] lines are mandatory here and absent from the Results<> version: the metadata has nowhere else to come from.

The gotcha that picks for you: content negotiation

If your API must honor Accept headers and return XML, CSV, or any format other than the one the result hard-codes, the HttpResults family is out, and that decision overrides everything above. The docs are explicit that the HttpResults types do “not leverage the configured Formatters,” and spell out the consequence: “Some features like Content negotiation aren’t available” and “The produced Content-Type is decided by the HttpResults implementation.” TypedResults.Ok(product) will serialize JSON regardless of what the client asked for. So an internal JSON-only API is free to use Results<> in a controller and enjoy the self-describing metadata, but a public API with an XML formatter registered has to stay on ActionResult<T> / IActionResult for the endpoints that negotiate. This is a capability wall, not a preference, which is why it belongs at the top of your decision and not the bottom.

The second forcing function is your hosting model. If the endpoint lives in a minimal API, IActionResult and ActionResult<T> are not even available to you; they are MVC types that depend on the controller pipeline. The choice there is only ever between IResult and Results<>, and Results<> wins for any multi-response endpoint. The full trade-off between the two hosting models is laid out in minimal APIs vs controllers in ASP.NET Core 11.

Why the typed versions do not compile by accident

There is one piece of friction people hit with Results<> and it is worth naming so it does not read as a bug. Type inference will not build the union for you. This does not compile:

// .NET 11, C# 14 -- does NOT compile
app.MapGet("/todos/{id}", async (int id, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is null
        ? TypedResults.NotFound()   // NotFound
        : TypedResults.Ok(todo);    // Ok<Todo>
});

TypedResults.NotFound() and TypedResults.Ok(todo) are different concrete types, so the compiler cannot find a common type for the ternary and the lambda has no inferable return type. The bare IResult version compiled only because every Results.* helper is already IResult, giving the branches an obvious shared type. With TypedResults you pay for the richer metadata by declaring the return type yourself: Results<Ok<Todo>, NotFound> for a sync handler or Task<Results<Ok<Todo>, NotFound>> for an async one. That declaration is not boilerplate you can shorten. It is the exact string the framework reads to build the spec, which is the entire point.

The same logic explains why ActionResult<IEnumerable<Product>> works but ActionResult<T> cannot wrap an interface you return directly: the implicit cast is defined from T, and C# forbids implicit casts on interfaces, so returning an IEnumerable instance needs an explicit Ok(...) wrapper. Small rule, occasionally surprising.

The recommendation, restated with the full picture

The mental model to keep: an interface return type accepts anything and documents nothing, so the framework makes you re-state the contract in attributes. A typed return type, Results<> or ActionResult<T>, is the contract, so the compiler enforces it and the OpenAPI generator reads it. Pick the typed one unless a concrete capability, almost always content negotiation, forces the interface. For the branches that return a validation failure, feeding a ProblemHttpResult into the union keeps the shape consistent with the built-in pipeline described in how to customize minimal API validation error responses with IProblemDetailsService.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back