Returning Results
Turning a handler's return value into an HTTP response with TypedResults, and why the declared return type is what OpenAPI reads.
app.MapGet("/books/{id:int}", Results<Ok<Book>, NotFound> (int id) => ...);A handler returns a value, and the framework has to turn it into an HTTP response. Return a plain object and you get 200 with that object as JSON. That is a shorthand, and it is enough until you need to say 201, 204 or 404.
Results and TypedResults
Two static classes offer the same helper names. Results.Ok(book) returns IResult, an interface with one method, ExecuteAsync, which writes the response. TypedResults.Ok(book) returns Ok<Book>, a concrete type that implements the same interface. The bytes on the wire are identical; the difference is what the compiler knows.
Why the return type matters
An OpenAPI document is a machine-readable description of your endpoints, used to generate clients and documentation. It is built from the handler's declared return type. Ok<Book> says "200 with a Book" by itself; IResult says nothing, and you have to add .Produces<Book>() by hand and keep it in step. A concrete type is also easier to assert on in a unit test.
Status codes that mean what they say
Use 200 when there is a body, 201 with a Location header when you created something, 204 when there is nothing to send, 404 for a thing that is not there, 409 for a conflict with existing state. A 200 containing {"error": ...} forces every client to parse the body to learn whether the call worked.
Example
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
Dictionary<int, string> books = new() { [1] = "Dune" };
// The declared return type lists every result this endpoint can produce.
// Returning anything else is a compile error, and OpenAPI reads both cases from it.
app.MapGet("/books/{id:int}", Results<Ok<string>, NotFound> (int id) =>
books.TryGetValue(id, out string? title)
? TypedResults.Ok(title)
: TypedResults.NotFound());
// 201 with a Location header pointing at the thing just created.
// title has no match in the route, so it is read from the query string.
app.MapPost("/books", (string title) =>
{
int id = books.Count + 1;
books[id] = title;
return TypedResults.Created($"/books/{id}", title);
});
// 204: the delete worked and there is nothing to send back.
app.MapDelete("/books/{id:int}", (int id) =>
{
books.Remove(id);
return TypedResults.NoContent();
});
app.Run();When to use it
- Return 404 rather than an empty 200 when a lookup by id finds nothing.
- Answer a POST with 201 and a Location header so the client knows the new resource's URL.
- Get a correct OpenAPI document without hand-writing a Produces call for every endpoint.
- Assert in a unit test that a handler returned NotFound, not just some IResult.
More examples
The same two responses, three ways
All three endpoints send the same bytes for a book that exists. The first cannot express a missing book. The second can, at the cost of two Produces calls that nothing checks against the code. The third states the contract once, in a place the compiler enforces. Prefer the third for anything that has more than one outcome.
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
Book? Find(int id) => id == 1 ? new Book(1, "Dune") : null;
// 1. Shorthand: a plain object becomes 200 with JSON. No way to say 404.
app.MapGet("/v1/books/{id:int}", (int id) => Find(id));
// 2. Results helpers all return IResult, so mixing them compiles,
// but OpenAPI learns nothing unless you describe the endpoint by hand.
app.MapGet("/v2/books/{id:int}", (int id) =>
Find(id) is Book book ? Results.Ok(book) : Results.NotFound())
.Produces<Book>()
.Produces(StatusCodes.Status404NotFound);
// 3. The typed form carries that description itself.
app.MapGet("/v3/books/{id:int}", Results<Ok<Book>, NotFound> (int id) =>
Find(id) is Book book ? TypedResults.Ok(book) : TypedResults.NotFound());
app.Run();
record Book(int Id, string Title);Testing a handler's result
Because TypedResults returns concrete types, a test can read StatusCode and Value straight off the result with no HTTP server involved. Had the handler returned IResult, the test would have to run the result against a fake HttpContext and inspect the written response. Pulling the handler out into a named static method is what makes it callable; the endpoint registration becomes app.MapGet("/books/{id:int}", BookEndpoints.Get).
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Xunit;
// In the web project: the handler is a named method so a test can call it directly.
public static class BookEndpoints
{
public static Results<Ok<string>, NotFound> Get(int id, Dictionary<int, string> books) =>
books.TryGetValue(id, out string? title)
? TypedResults.Ok(title)
: TypedResults.NotFound();
}
// In the test project:
public class BookEndpointTests
{
[Fact]
public void Get_ReturnsTheTitle_WhenTheBookExists()
{
Dictionary<int, string> books = new() { [1] = "Dune" };
Results<Ok<string>, NotFound> result = BookEndpoints.Get(1, books);
// Result is the one of the two the handler actually chose.
Ok<string> ok = Assert.IsType<Ok<string>>(result.Result);
Assert.Equal("Dune", ok.Value);
Assert.Equal(StatusCodes.Status200OK, ok.StatusCode);
}
[Fact]
public void Get_ReturnsNotFound_WhenTheBookIsMissing()
{
Results<Ok<string>, NotFound> result = BookEndpoints.Get(99, []);
Assert.IsType<NotFound>(result.Result);
}
}Created, and where the Location comes from
A 201 should tell the client where the new resource lives. CreatedAtRoute asks routing for the URL of a named endpoint, so changing the route template in one place cannot leave a stale string behind. Use the literal-path overload for simple cases and the route-name overload once the same URL is built in more than one handler.
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
Dictionary<int, Book> books = new();
// Named with WithName so other endpoints can build its URL by name.
app.MapGet("/books/{id:int}", Results<Ok<Book>, NotFound> (int id) =>
books.TryGetValue(id, out Book? book)
? TypedResults.Ok(book)
: TypedResults.NotFound())
.WithName("GetBook");
app.MapPost("/books", (Book book) =>
{
books[book.Id] = book;
// Longhand: build the path yourself.
// return TypedResults.Created($"/books/{book.Id}", book);
// Shorthand: name the route and let routing build the path.
// Both set a Location header and a 201 status.
return TypedResults.CreatedAtRoute(book, "GetBook", new { id = book.Id });
});
app.Run();
record Book(int Id, string Title);
Discussion