Union types en C# 15: qué cambian y qué no resuelven
C# 15 trae union types con switch exhaustivo. Cómo se declaran, qué genera el compilador, cuánto cuestan y por qué conviene esperar para producción.
Un método que cancela un pedido puede terminar de tres formas: se canceló, el pedido no existe o ya fue despachado. En C#, hasta ahora, había que elegir entre opciones incómodas: lanzar excepciones para resultados que no son excepcionales, devolver un Result con un bool y un error que nadie obliga a revisar, o armar una jerarquía de clases con un switch que necesita un default por si acaso.
Ese default es el problema. Cuando alguien agrega un cuarto resultado, el compilador no avisa en ninguno de los lugares donde había que manejarlo. El error aparece en ejecución.
C# 15, que se publica con .NET 11 en noviembre de 2026, incorpora union types para cerrar ese hueco. Este artículo se basa en las versiones preliminares; algunos detalles pueden cambiar antes de la versión final.
La declaración
public record Cancelled(DateTimeOffset At);
public record NotFound;
public record AlreadyShipped(DateTimeOffset ShippedAt);
public union CancelResult(Cancelled, NotFound, AlreadyShipped);
Una union declara un conjunto cerrado de tipos posibles. Los tipos de cada caso no tienen que compartir una clase base ni una interfaz: pueden ser records, clases, structs, tipos primitivos (union IntOrString(int, string)) e incluso tipos genéricos.
Cada tipo de caso se convierte de forma implícita a la union, así que el método devuelve el valor tal cual:
public CancelResult Cancel(Order? order, DateTimeOffset now)
{
if (order is null) return new NotFound();
if (order.ShippedAt is { } shippedAt) return new AlreadyShipped(shippedAt);
order.MarkCancelled(now);
return new Cancelled(now);
}
Lo que de verdad aporta: el switch exhaustivo
IResult response = result switch
{
Cancelled => Results.NoContent(),
NotFound => Results.NotFound(),
AlreadyShipped s => Results.Conflict($"Despachado el {s.ShippedAt:d}"),
};
No hay default ni descarte (_). El compilador conoce la lista completa de casos y sabe que están cubiertos. Si mañana se agrega PaymentPending a la declaración, cada switch que no lo maneje genera una advertencia de compilación.
Ese es el cambio de fondo: el conjunto de resultados pasa a ser parte del contrato del método, y el compilador lo hace cumplir. Con TreatWarningsAsErrors activado, olvidar un caso impide compilar.
Qué genera el compilador
Conviene saberlo porque explica los límites. Una declaración union se convierte en un struct con un constructor por cada caso y una única propiedad:
[Union]
public struct CancelResult : IUnion
{
public CancelResult(Cancelled value) => Value = value;
public CancelResult(NotFound value) => Value = value;
public CancelResult(AlreadyShipped value) => Value = value;
public object? Value { get; }
}
De ahí salen tres consecuencias prácticas:
- El contenido se guarda como
object?. Si un caso es un tipo por valor (int, unstruct), se hace boxing al guardarlo. En un resultado de un caso de uso es irrelevante; en un bucle de millones de iteraciones, no. - El valor por defecto existe y está vacío.
default(CancelResult)tieneValueennull. Cuando el compilador no puede garantizar que la union fue asignada, pide manejar tambiénnullen elswitch. - Los patrones se aplican al contenido, no a la union. Por eso
result is Cancelledfunciona directo, yresult is CancelResultnormalmente no coincide.
Para los casos en que el boxing importa, se puede escribir la union a mano: cualquier struct o clase con el atributo [Union], constructores públicos de un parámetro y una propiedad Value cuenta como tal, y puede implementar HasValue y TryGetValue para que el compilador evite el boxing. Es más código; se justifica en rutas críticas, no como estilo general.
Unions frente a lo que ya existía
| Alternativa | Qué ofrece | Qué le falta |
|---|---|---|
| Excepciones | Simples, cortan el flujo | Los resultados posibles no aparecen en la firma; son costosas como control de flujo |
Result con IsSuccess y Error |
Sin dependencias, fácil de entender | Nada obliga a revisar el error ni a cubrir todos los tipos de error |
| Clase base abstracta + derivadas | Funciona hoy, con pattern matching | El switch necesita default; un tipo nuevo no genera aviso |
| Bibliotecas como OneOf o ErrorOr | Exhaustividad mediante Match |
Dependencia externa; firmas genéricas largas (OneOf<A, B, C>) |
union (C# 15) |
Exhaustividad en el lenguaje, sin herencia | Requiere .NET 11; todavía en versión preliminar |
C# 15 trae además las jerarquías cerradas (closed record class JobStatus;), que logran exhaustividad con herencia: solo se puede derivar dentro del mismo ensamblado. La elección entre ambas depende de si los casos comparten datos y comportamiento (jerarquía cerrada) o son tipos independientes que solo se agrupan (union).
Los trade-offs
Agregar un caso rompe a quienes consumen. Es la intención de la característica, pero cambia el costo de evolucionar un contrato. En una union expuesta por una biblioteca pública, un caso nuevo es un cambio incompatible para todos los que la usan.
No son un tipo de transporte. Una union vive bien dentro del proceso. Para cruzar HTTP o una cola hay que decidir cómo se serializa cada caso; con las versiones preliminares conviene mapearla a un DTO explícito en el borde, igual que con las entidades, como explicamos en no expongas tus entidades.
Mezclan mal con valores ambiguos. Si un valor se puede convertir a dos casos distintos, la conversión implícita es ambigua y el compilador la rechaza. Las unions funcionan mejor con casos claramente diferentes.
El resultado sigue teniendo que traducirse. Una union no reemplaza el punto único donde los errores esperados se convierten en respuestas HTTP; lo hace más seguro. Las excepciones siguen siendo el mecanismo para lo inesperado, con su manejo global.
Cuándo usarlas
- Resultados de casos de uso con varios desenlaces esperados y distintos entre sí.
- Mensajes o comandos de un conjunto cerrado que se despachan con un
switch. - Estados con datos distintos por estado, donde hoy hay una clase con campos que solo aplican "a veces".
- Reemplazar interfaces marcadoras que existen solo para agrupar tipos en un
switch.
Cuándo no
- En producción, todavía. Hasta que .NET 11 salga y el equipo lo adopte, es una característica preliminar. .NET 11 además es una versión de soporte estándar; muchos equipos se quedan en .NET 10, que es LTS, y ahí las unions no están disponibles.
- Para un éxito o un único error. Un
bool, un valor anulable o unResultsimple comunican lo mismo con menos ceremonia. - Para errores realmente excepcionales (la base de datos no responde, un argumento inválido por un error de programación): eso sigue siendo una excepción.
- En conjuntos abiertos. Si otros ensamblados deben poder agregar casos, lo que se necesita es una interfaz, no una union.
- En rutas críticas de rendimiento con tipos por valor, salvo que se escriba la variante sin boxing.
Conclusión
Los union types no agregan una capacidad nueva a C#: todo lo que expresan ya se podía modelar. Lo que agregan es que el compilador verifique que cada desenlace fue considerado, y eso elimina una clase de errores que hoy solo se descubre en ejecución. Vale la pena probarlos ya en un proyecto de prueba con .NET 11 para formarse un criterio; para el código que hoy está en producción, la decisión razonable es esperar a la versión final y a que el equipo defina cuándo migra de versión.
¿Necesitas un equipo .NET para tu proyecto?
Construimos y modernizamos backends en .NET y SQL Server: APIs, integraciones y sistemas empresariales a medida.
Ver desarrollo a medida