C# llevaba años resolviendo un problema cotidiano con herramientas que funcionaban, pero que no expresaban del todo bien la intención: un valor puede ser exactamente uno entre varios tipos conocidos.
Podíamos usar object, una jerarquía de clases, wrappers genéricos, records, librerías como OneOf, o convenciones propias. Cada opción tenía un coste. object permitía demasiado; una jerarquía de herencia no sirve cuando los casos son tipos no relacionados como int y string; y un wrapper manual obliga a inventar construcción, acceso, serialización y pattern matching.
Con C# 15 y .NET 11, Microsoft introduce dos piezas que atacan el problema directamente desde el lenguaje:
union, para representar una lista cerrada de tipos alternativos;closed, para declarar jerarquías de herencia cuyo conjunto de derivados conocidos es finito para el compilador.
La diferencia parece sintáctica, pero el efecto importante está en tres sitios: el compilador, el contrato JSON y la evolución de APIs.
La documentación oficial publicada por Microsoft muestra además que estas características no se quedan en el lenguaje: están integradas con System.Text.Json, Minimal APIs, MVC, SignalR, Blazor y OpenAPI.
Fuente principal: Use C# unions and closed hierarchies in ASP.NET Core.
El problema clásico: “puede ser esto o aquello”
Un ejemplo real viene de Kubernetes. El campo maxUnavailable puede representarse como un número absoluto o como un porcentaje:
2
o:
"25%"
En C# tradicional podríamos modelarlo así:
object maxUnavailable;
Pero object no dice “int o string”. Dice “cualquier cosa”. También admitiría:
maxUnavailable = true;
maxUnavailable = DateTime.UtcNow;
maxUnavailable = new HttpClient();
El contrato real desaparece del sistema de tipos.
Con C# 15 podemos escribir:
public union IntOrString(int, string);
Ahora el tipo expresa exactamente el dominio permitido:
IntOrString absolute = 2;
IntOrString percentage = "25%";
Un bool ya no pertenece al conjunto.
Eso convierte una convención de documentación en una restricción verificable por el compilador.
La propiedad más importante: switch exhaustivo
El valor real de un union no es ahorrar unas líneas. Es que el compilador conoce todos los casos posibles.
static string Describe(IntOrString value) => value switch
{
int count => $"{count} pods",
string percentage => percentage,
};
No hace falta un brazo _ => ... simplemente para satisfacer al compilador.
Si más tarde el union evoluciona:
public union SettingValue(bool, decimal, string);
pero el código sigue manejando solamente:
static string Describe(SettingValue value) => value switch
{
bool enabled => enabled ? "enabled" : "disabled",
decimal number => number.ToString(),
};
C# puede advertir que falta el caso string.
Este detalle es enorme para sistemas grandes. Agregar un nuevo estado hace visibles los puntos del código que todavía no saben manejarlo.
Ese patrón conecta directamente con una idea muy útil en diseño de software: hacer que los estados imposibles sean difíciles —o idealmente imposibles— de representar.
union no es simplemente “herencia más corta”
Un union puede contener tipos que no tienen ninguna relación entre sí:
public union ResultOrError(Result, ApiError);
pero también primitivas:
public union StringOrInt(string, int);
interfaces:
public union Source(Stream, IAsyncEnumerable<byte>);
o valores nullable:
public union NullableIntOrString(int?, string);
No hace falta diseñar una clase base común artificial.
Eso lo hace especialmente útil cuando el contrato ya existe y no podemos cambiar los tipos participantes.
Entonces, ¿qué es una jerarquía closed?
C# 15 añade también jerarquías cerradas.
public closed record class PaymentEvent(string PaymentId);
public sealed record class PaymentInitiated(string PaymentId)
: PaymentEvent(PaymentId);
public sealed record class PaymentAuthorized(
string PaymentId,
decimal Amount)
: PaymentEvent(PaymentId);
public sealed record class PaymentFailed(
string PaymentId,
string Reason)
: PaymentEvent(PaymentId);
Aquí seguimos teniendo herencia real. Los casos comparten una clase base, miembros y comportamiento. La diferencia es que la jerarquía se declara cerrada para que el lenguaje pueda razonar sobre el conjunto de derivados conocidos.
Eso permite nuevamente pattern matching exhaustivo:
static string Describe(PaymentEvent e) => e switch
{
PaymentInitiated x => $"Started: {x.PaymentId}",
PaymentAuthorized x => $"Authorized: {x.Amount}",
PaymentFailed x => $"Failed: {x.Reason}",
};
La pregunta práctica pasa a ser:
¿los casos pertenecen conceptualmente a una familia común?
Si la respuesta es sí, una jerarquía cerrada suele modelar mejor el dominio.
union vs closed: una regla útil
Una forma sencilla de decidir:
Casos heterogéneos o tipos existentes
↓
union
Casos relacionados bajo una misma abstracción
↓
closed hierarchy
Ejemplos típicos para union:
int | string
Guid | string
Stream | byte[]
ExistingDtoA | ExistingDtoB
Ejemplos típicos para closed:
PaymentInitiated
PaymentAuthorized
PaymentFailed
Microsoft recomienda una distinción todavía más importante para APIs: si diseñas un contrato nuevo y controlas todas las clases, una jerarquía cerrada con discriminator JSON suele ser preferible. Si debes preservar un contrato ya existente sin discriminator, o los casos ni siquiera pueden compartir clase base, el union encaja mejor.
Qué ocurre en JSON
Aquí empieza la parte realmente interesante para ASP.NET Core.
System.Text.Json entiende los unions de forma nativa.
public union UnionIntString(int, string);
Serializar:
JsonSerializer.Serialize(new UnionIntString(42));
produce:
42
Mientras que:
JsonSerializer.Serialize(new UnionIntString("hello"));
produce:
"hello"
No aparece un wrapper como:
{
"type": "int",
"value": 42
}
ni un $type implícito.
El union se “desenvuelve” y se serializa solamente el caso activo.
Esto es excelente para compatibilidad con contratos existentes.
El problema difícil: deserializar casos ambiguos
Escribir un union es sencillo porque ya sabemos qué caso está activo.
Leerlo puede ser más difícil.
Supongamos:
public record Cat(string Name, string Coat);
public record Dog(string Name, string Breed);
public union Pet(Cat, Dog);
Ambos casos son objetos JSON:
{
"name": "Milo",
"coat": "Tabby"
}
versus:
{
"name": "Max",
"breed": "Labrador"
}
El parser ve StartObject en ambos. No existe un discriminator que diga explícitamente qué tipo construir.
Para contratos que no pueden modificarse, .NET ofrece clasificación estructural:
[JsonUnion(
TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Pet(Cat, Dog);
El clasificador inspecciona las propiedades para inferir el caso.
Funciona, pero tiene un coste conceptual: la decisión depende de la forma del payload. Si los objetos evolucionan y empiezan a compartir propiedades, puede aparecer ambigüedad.
Además, el clasificador debe inspeccionar el objeto antes de deserializarlo, por lo que añade trabajo proporcional al tamaño del JSON.
Para una API nueva, un discriminator explícito suele ser más estable.
Closed hierarchies y discriminators
El modificador closed por sí solo no cambia el JSON.
Si serializamos un tipo concreto:
var evt = new PaymentAuthorized("p-123", 42.5m);
podemos obtener algo como:
{
"paymentId": "p-123",
"amount": 42.5
}
Pero si el endpoint recibe o devuelve la clase base PaymentEvent, el consumidor necesita saber cuál derivado reconstruir.
Ahí entra:
[JsonPolymorphic(InferClosedTypePolymorphism = true)]
public closed record class PaymentEvent(string PaymentId);
Entonces System.Text.Json puede inferir los derivados de la jerarquía cerrada y producir un discriminator:
{
"$type": "PaymentAuthorized",
"paymentId": "p-123",
"amount": 42.5
}
La separación es importante:
closed
→ semántica del tipo y exhaustividad en C#
JsonPolymorphic
→ representación polimórfica en JSON
Son problemas relacionados, pero no idénticos.
Minimal APIs
Los unions funcionan como cuerpos de request y tipos de retorno.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/flag", (UnionBoolString flag) => flag);
app.MapGet("/value", () => new UnionIntString(42));
app.Run();
La integración existe tanto en el runtime path de RequestDelegateFactory como en el Request Delegate Generator.
También pueden envolverse en tipos habituales:
Task<MyUnion>
ValueTask<MyUnion>
MyUnion?
TypedResults.Ok(myUnion)
e incluso aparecer dentro de otros modelos o flujos IAsyncEnumerable<T>.
MVC
Los controladores también los aceptan porque pasan por los formatters de System.Text.Json:
[ApiController]
[Route("[controller]/[action]")]
public class ValuesController : ControllerBase
{
[HttpPost]
public UnionBoolString Echo([FromBody] UnionBoolString value)
=> value;
[HttpGet("{kind}")]
public UnionIntString Get(string kind)
=> kind == "number" ? 42 : "hello";
}
La lógica de serialización y clasificación es la misma que en Minimal APIs.
SignalR
JsonHubProtocol también delega en System.Text.Json, por lo que un union puede viajar como argumento, retorno o elemento de un stream:
public class ChatHub : Hub
{
public Task Send(UnionIntString message)
=> Clients.All.SendAsync("Receive", message);
public UnionIntString GetValue()
=> "hello";
}
Una limitación importante: esta compatibilidad aplica a JsonHubProtocol. Los protocolos basados en MessagePack o Newtonsoft.Json no obtienen soporte automáticamente porque sus serializadores no conocen el nuevo concepto.
Blazor
En Blazor hay dos escenarios.
Si el valor permanece dentro del mismo proceso, un parámetro de componente puede ser un union sin serialización especial.
Si cruza una frontera que usa System.Text.Json —por ejemplo JavaScript interop, estado persistido o prerendering— entra en juego la misma infraestructura de unions.
Un ejemplo interesante es una API JavaScript que acepta dos formas distintas:
public sealed record ScrollIntoViewOptions(
string Behavior,
string Block,
string Inline);
public union ScrollIntoViewArgument(
bool,
ScrollIntoViewOptions);
El mismo método C# puede enviar un bool o un objeto de opciones sin introducir un wrapper artificial en JavaScript.
OpenAPI: anyOf
Una API no termina en el runtime. El contrato debe poder describirse.
ASP.NET Core representa un union con anyOf:
{
"anyOf": [
{ "type": "integer", "format": "int32" },
{ "type": "string" }
]
}
Eso es exactamente lo que OpenAPI necesita para expresar “el valor puede tener una de estas formas”.
Para objetos:
{
"anyOf": [
{ "$ref": "#/components/schemas/Cat" },
{ "$ref": "#/components/schemas/Dog" }
]
}
El resultado es especialmente valioso para generación de clientes, documentación y validadores automáticos: el concepto del lenguaje llega hasta el contrato de la API.
Lo que todavía NO funciona
El soporte de ASP.NET Core depende de System.Text.Json.
Por eso los unions funcionan donde realmente existe una frontera JSON, pero no en fuentes de binding que comienzan como texto plano.
No están soportados directamente en:
query strings
route values
headers
form fields
El motivo es sencillo.
Dado:
?id=42
¿42 es:
int
string
Guid
long
?
Sin estructura JSON adicional, no existe suficiente información para decidir de manera universal.
Por tanto esto no debe interpretarse como “union funciona en cualquier parámetro de ASP.NET Core”. El soporte actual está ligado a los pipelines que utilizan System.Text.Json.
Una sutileza: int | string puede ser ambiguo al leer JSON web
A primera vista parece que un número JSON debería mapear a int y una cadena JSON a string.
Pero las opciones web de System.Text.Json pueden permitir números leídos desde strings. Eso significa que:
"42"
podría coincidir con más de un caso en determinados contextos de deserialización HTTP.
La escritura sigue siendo trivial; la lectura puede requerir clasificación explícita.
Es un recordatorio importante: los union types resuelven el modelo de tipos, no eliminan mágicamente las ambigüedades del protocolo.
Qué cambia frente a OneOf<T1,T2> y wrappers propios
Una librería externa puede seguir siendo válida, especialmente en código que no está todavía en C# 15. Pero el salto de una construcción de librería a una construcción de lenguaje tiene consecuencias importantes.
Con un union nativo:
el compilador conoce los casos
↓
pattern matching exhaustivo
↓
mejores diagnósticos al evolucionar
↓
integración directa con runtime y frameworks
Un wrapper externo puede imitar parte de ese comportamiento, pero no tiene el mismo nivel de conocimiento dentro del compilador y del ecosistema base.
Por qué esto importa para domain modeling
Supongamos una operación que solo puede terminar en tres estados:
public union OperationResult(
Success,
RetryLater,
PermanentFailure);
El consumidor está obligado conceptualmente a pensar en los tres.
return result switch
{
Success s => HandleSuccess(s),
RetryLater r => ScheduleRetry(r),
PermanentFailure f => Escalate(f),
};
Esto es mucho más fuerte que devolver:
object
o incluso un DTO con campos opcionales:
class Result
{
public bool Success { get; set; }
public string? RetryReason { get; set; }
public string? Error { get; set; }
}
Ese DTO permite combinaciones absurdas:
Success = true
RetryReason != null
Error != null
Un union puede expresar estados mutuamente excluyentes directamente.
Conexión con algebraic data types
El concepto no es nuevo en informática.
Lenguajes como F#, Rust, Swift y otros llevan años usando variantes cerradas para representar estados del dominio.
La forma matemática habitual es pensar en un “sum type”:
Result = Success + Retry + Failure
No significa suma numérica. Significa que un valor de Result pertenece a exactamente uno de esos casos.
C# ha ido acercándose a este estilo mediante records, pattern matching y mejoras continuas en exhaustividad. union y closed hacen que esa idea sea mucho más explícita.
Una guía práctica para diseñar APIs nuevas
Una heurística útil:
Usa union cuando
- necesites combinar tipos no relacionados;
- el contrato JSON existente no tenga discriminator;
- haya primitivas entre los casos;
- no controles los tipos participantes;
- quieras preservar exactamente la forma JSON actual.
Usa una jerarquía closed cuando
- controles todos los casos;
- pertenezcan a una misma familia conceptual;
- puedan compartir datos o comportamiento;
- estés diseñando un contrato nuevo;
- un discriminator explícito mejore la robustez del protocolo.
Mantén la jerarquía abierta cuando
- terceros deban poder extenderla;
- el conjunto de derivados no sea realmente finito;
- quieras que los consumidores implementen un fallback para tipos futuros.
Evolución de contratos: el beneficio y el coste
La exhaustividad introduce una tensión interesante.
Si tenemos:
public union Status(Ready, Running, Failed);
y añadimos:
Paused
el compilador puede señalar los switch que quedaron incompletos.
Eso es fantástico dentro de un mismo codebase.
Pero también significa que agregar un caso puede convertirse en un cambio importante para consumidores externos que hayan escrito lógica exhaustiva.
Es decir, un conjunto cerrado crea una promesa fuerte:
estos son todos los casos
Cambiar ese conjunto merece el mismo cuidado que cambiar cualquier contrato público.
El cambio conceptual
Durante mucho tiempo C# obligó a elegir entre dos extremos:
flexibilidad demasiado abierta
vs.
jerarquías de herencia diseñadas a mano
Ahora aparece una tercera opción mucho más precisa:
conjunto cerrado de alternativas
Eso permite escribir contratos como:
public union IntOrString(int, string);
sin perder el soporte normal de C# para patterns, JSON, ASP.NET Core y OpenAPI.
Y permite cerrar una jerarquía real:
public closed record class PaymentEvent(...);
sin renunciar a herencia, comportamiento compartido y discriminators.
Conclusión
Los nuevos union y closed de C# 15 no son simplemente azúcar sintáctica.
Añaden una herramienta de modelado que faltaba en el lenguaje y la conectan con el stack web completo de .NET 11.
La consecuencia más útil puede resumirse así:
menos estados implícitos
+ contratos más precisos
+ switches exhaustivos
+ JSON/OpenAPI coherentes
= errores detectados antes
Para APIs existentes con formas heterogéneas, union puede eliminar mucho código accidental. Para dominios nuevos y familias de eventos bien definidas, una jerarquía closed con discriminator suele ser todavía mejor.
La decisión final no debería empezar por la sintaxis, sino por una pregunta de diseño:
¿Estoy representando tipos heterogéneos que comparten un contrato, o estados relacionados que pertenecen a una misma familia?
Cuando esa respuesta está clara, C# 15 ofrece ahora una forma mucho más directa de convertirla en código.