Start Debugging

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

En ASP.NET Core 11, devuelve Results<T1, TN> con TypedResults para minimal APIs y ActionResult<T> para controladores. Trata IResult e IActionResult a secas como escotillas de escape: compilan para cualquier respuesta pero no le describen nada a OpenAPI, así que los pagas con atributos ProducesResponseType escritos a mano.

Si tu endpoint tiene una sola respuesta posible, declara ese tipo concreto y sigue adelante. Si tiene varias, la respuesta precisa en ASP.NET Core 11 es: devuelve Results<TResult1, TResultN> con TypedResults desde una minimal API, y ActionResult<T> desde un controlador. Ambos te dan verificación en tiempo de compilación de que el handler solo devuelve lo que declara, y ambos le entregan al generador de OpenAPI los metadatos de la respuesta gratis. Los dos tipos de interfaz, IResult a secas e IActionResult a secas, son escotillas de escape: compilan sin importar lo que devuelvas, que es exactamente por lo que no le describen nada al framework y te obligan a escribir a mano [ProducesResponseType] o .Produces para obtener una especificación precisa. Todo lo que sigue apunta a .NET 11 con Microsoft.NET.Sdk.Web y C# 14; los tipos de HttpResults se han comportado igual desde .NET 7, así que el mismo código corre sin cambios en .NET 10 GA.

Los tres contendientes del título de la cola se corresponden con dos mundos distintos. IActionResult es el mundo de los controladores MVC. IResult y su unión tipada Results<> son el mundo de las minimal API construido sobre el namespace Microsoft.AspNetCore.Http.HttpResults. El detalle que hace que valga la pena escribir esta comparación es que, a partir de .NET 7, los tipos de HttpResults también funcionan en controladores, así que en una acción de controlador ahora tienes una elección genuina entre los tipos de resultado de MVC y los de minimal API. Elegir bien significa entender qué carga y qué no carga cada tipo.

La matriz de características

CaracterísticaIActionResultActionResult<T>IResult (a secas)Results<T1, TN>
Hogar principalControladoresControladoresMinimal APIs + controladoresMinimal APIs + controladores
Se autodescribe a OpenAPINoParcial (infiere T)No
Necesita [ProducesResponseType] / .ProducesSí, con larguezaPara códigos de estado que no son TNo
Verificación de retorno en compilaciónNoNoNo
Negociación de contenido / formateadoresNoNo
Cast implícito desde el tipo de payloadNo (interfaz)Sí (T a ActionResult<T>)NoSí (cada argumento de la unión)
Resultado directamente testeableRequiere castRequiere castRequiere cast.Result concreto

Lee la matriz de arriba abajo y el patrón es claro. Las dos filas de interfaz dicen “No” en cada columna de metadatos y seguridad. Las dos filas tipadas se ganan su verbosidad convirtiendo el “No” en “Sí”. La única columna donde las interfaces y ActionResult<T> le ganan a los tipos de HttpResults es la negociación de contenido, y esa única fila es la trampa que de vez en cuando elige por ti. Más sobre ella abajo.

Cuándo elegir Results<> (y TypedResults)

Ve por la unión siempre que un endpoint de minimal API pueda responder con más de una forma.

Esta es la forma canónica de una minimal API:

// .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);
});

Sin .Produces, y el documento OpenAPI generado lista un 200 con un esquema Todo y un 404 sin cuerpo, ambos derivados del tipo de retorno. La conversión paso a paso, el techo de seis tipos y la ganancia en testeo se cubren en profundidad en cómo devolver una unión tipada Results desde un endpoint de minimal API; este post trata sobre cuándo elegirla frente a las alternativas, no sobre cómo conectarla.

Cuándo elegir ActionResult

Ve por ActionResult<T> cuando estás escribiendo una acción de controlador con un payload de éxito principal y una o más ramas de error.

// .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>
}

La razón por la que ActionResult<T> existe y IActionResult no puede reemplazarlo es una regla de C#, no una decisión del framework: C# no permite operadores de cast implícito en interfaces. ActionResult<T> es un tipo genérico concreto, así que puede definir la conversión implícita desde T que te deja escribir return product;. IActionResult es una interfaz, así que nunca puede. Esa es toda la brecha ergonómica entre los dos.

Cuándo IActionResult o IResult a secas es realmente correcto

Ninguna interfaz está mal, solo son estrechas. Úsalas deliberadamente, no por defecto.

La versión de IResult a secas en un controlador se ve así, y nota que los atributos están de vuelta:

// .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);
}

Cada helper Results.* devuelve IResult, así que el compilador infiere IResult para ambas ramas y nunca se queja, y el ApiExplorer ve una interfaz que no dice nada sobre códigos de estado. Por eso las dos líneas de [ProducesResponseType] son obligatorias aquí y están ausentes de la versión con Results<>: los metadatos no tienen de dónde más venir.

La trampa que elige por ti: la negociación de contenido

Si tu API debe honrar cabeceras Accept y devolver XML, CSV o cualquier formato distinto del que el resultado codifica fijo, la familia HttpResults queda descartada, y esa decisión anula todo lo anterior. La documentación es explícita en que los tipos de HttpResultsno aprovechan los formateadores configurados”, y deletrea la consecuencia: “Algunas características como la negociación de contenido no están disponibles” y “El Content-Type producido lo decide la implementación de HttpResults.” TypedResults.Ok(product) serializará JSON sin importar lo que pidió el cliente. Así que una API interna solo-JSON es libre de usar Results<> en un controlador y disfrutar de los metadatos autodescriptivos, pero una API pública con un formateador XML registrado tiene que quedarse en ActionResult<T> / IActionResult para los endpoints que negocian. Esto es un muro de capacidad, no una preferencia, que es por lo que pertenece a la cima de tu decisión y no al fondo.

La segunda función forzante es tu modelo de hosting. Si el endpoint vive en una minimal API, IActionResult y ActionResult<T> ni siquiera están disponibles para ti; son tipos de MVC que dependen del pipeline de controladores. La elección allí es solo entre IResult y Results<>, y Results<> gana para cualquier endpoint de múltiples respuestas. La compensación completa entre los dos modelos de hosting está expuesta en minimal APIs vs controladores en ASP.NET Core 11.

Por qué las versiones tipadas no compilan por accidente

Hay una fricción que la gente encuentra con Results<> y vale la pena nombrarla para que no se lea como un bug. La inferencia de tipos no construirá la unión por ti. Esto no compila:

// .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() y TypedResults.Ok(todo) son tipos concretos diferentes, así que el compilador no puede encontrar un tipo común para el ternario y la lambda no tiene un tipo de retorno inferible. La versión de IResult a secas compiló solo porque cada helper Results.* ya es IResult, dándole a las ramas un tipo compartido obvio. Con TypedResults pagas los metadatos más ricos declarando el tipo de retorno tú mismo: Results<Ok<Todo>, NotFound> para un handler síncrono o Task<Results<Ok<Todo>, NotFound>> para uno asíncrono. Esa declaración no es texto repetitivo que puedas acortar. Es la cadena exacta que el framework lee para construir la especificación, que es todo el punto.

La misma lógica explica por qué ActionResult<IEnumerable<Product>> funciona pero ActionResult<T> no puede envolver una interfaz que devuelves directamente: el cast implícito está definido desde T, y C# prohíbe los cast implícitos en interfaces, así que devolver una instancia de IEnumerable necesita un envoltorio explícito Ok(...). Regla pequeña, ocasionalmente sorprendente.

La recomendación, reafirmada con el panorama completo

El modelo mental a conservar: un tipo de retorno de interfaz acepta cualquier cosa y no documenta nada, así que el framework te obliga a re-declarar el contrato en atributos. Un tipo de retorno tipado, Results<> o ActionResult<T>, es el contrato, así que el compilador lo hace cumplir y el generador de OpenAPI lo lee. Elige el tipado a menos que una capacidad concreta, casi siempre la negociación de contenido, fuerce la interfaz. Para las ramas que devuelven una falla de validación, meter un ProblemHttpResult en la unión mantiene la forma consistente con el pipeline integrado descrito en cómo personalizar las respuestas de error de validación de minimal API con IProblemDetailsService.

Relacionado

Fuentes

Comments

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

< Volver