Tipos de sindicatos de C#: los sindicatos discriminados finalmente están llegando
Si ha estado escribiendo C# durante un período de tiempo significativo, es muy probable que se haya topado con una pared al intentar modelar algo que puede ser “una de varias cosas”. Quizás necesitabas un método para devolver un valor de éxito o un error. Quizás estabas creando un sistema de pago que maneja tarjetas de crédito, transferencias bancarias y billeteras digitales, cada una con datos completamente diferentes. O tal vez simplemente miraste F# o Rust y pensaste: “¿Por qué no puedo tener eso en C#?”
La espera casi ha terminado. Los sindicatos discriminados están llegando a C#.
Esta ha sido una de las funciones lingüísticas más solicitadas durante años, con debates comunitarios que se remontan a 2017 y antes. El equipo de diseño del lenguaje C# ha estado trabajando en una propuesta que introduce una palabra clave union para definir jerarquías de tipos cerrados con coincidencia de patrones exhaustiva. En esta publicación, quiero explicarle qué son las uniones discriminadas, por qué son tan importantes, cómo las hemos falsificado hasta ahora y cómo se ve realmente la sintaxis propuesta, con ejemplos de código real para cada una.
Una nota rápida antes de profundizar: al momento de escribir este artículo, la función de tipos de unión aún se encuentra en la etapa de propuesta y vista previa. La sintaxis y el comportamiento que describo aquí se basan en los últimos documentos de diseño disponibles públicamente y debates del equipo de diseño del lenguaje C#. Las cosas pueden cambiar antes del lanzamiento final. Seré claro sobre lo que está confirmado y lo que aún está en discusión.
¿Qué son los sindicatos discriminados?
En esencia, una unión discriminada (a veces denominada “unión etiquetada” o “tipo de suma”) es un tipo que puede contener uno de un conjunto fijo de valores posibles, donde cada variante puede contener datos diferentes. La parte “discriminada” significa que el tiempo de ejecución siempre sabe qué variante es; hay una etiqueta que la identifica.
Piense en ello como un enum, pero donde cada miembro puede llevar su propia carga de datos.
Si has usado otros lenguajes, probablemente hayas visto este concepto antes:
F# ha tenido sindicatos discriminados desde el primer día:
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
| Triangle of base: float * height: float
Rust usa enum para la misma idea:
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
TypeScript logra algo similar con uniones etiquetadas:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
En todos estos casos, el compilador conoce todas las variantes posibles y puede exigir que usted las maneje todas. Ése es el superpoder: comprobación exhaustiva. Si agrega una nueva variante, el compilador le indica dónde olvidó manejarla.
C# nunca ha tenido una forma excelente de expresar esto. Hasta ahora.
Cómo simulamos los sindicatos hoy
A lo largo de los años, la comunidad C# ha ideado varias soluciones, cada una con sus propias compensaciones. Permítanme repasar los enfoques más comunes.
Registros abstractos con herencia
La solución más idiomática en C# moderno es usar registros abstractos con tipos derivados sellados:
public abstract record Shape
{
public sealed record Circle(double Radius) : Shape;
public sealed record Rectangle(double Width, double Height) : Shape;
public sealed record Triangle(double Base, double Height) : Shape;
private Shape() { } // Prevent external inheritance
}
Esto funciona razonablemente bien. Obtienes inmutabilidad, igualdad de valores y puedes usar la coincidencia de patrones:
double Area(Shape shape) => shape switch
{
Shape.Circle c => Math.PI * c.Radius * c.Radius,
Shape.Rectangle r => r.Width * r.Height,
Shape.Triangle t => 0.5 * t.Base * t.Height,
_ => throw new InvalidOperationException("Unknown shape")
};
```Pero existen importantes inconvenientes. El compilador no sabe que la jerarquía está cerrada, por lo que siempre necesita ese `_` descartar el brazo o recibirá una advertencia. Si agrega una nueva variante, el compilador no le informará sobre todos los lugares donde olvidó manejarla: el descarte la traga silenciosamente. Eso anula completamente el propósito.
### La biblioteca OneOf
Otro enfoque popular es el paquete NuGet [OneOf](https://github.com/mcintyre321/OneOf):
```csharp
public OneOf<Success<Order>, NotFound, ValidationError> ProcessOrder(OrderRequest request)
{
// ...
}
// Usage
var result = ProcessOrder(request);
result.Switch(
success => Console.WriteLine($"Order {success.Value.Id} processed"),
notFound => Console.WriteLine("Order not found"),
error => Console.WriteLine($"Validation failed: {error.Message}")
);
OneOf proporciona una verificación exhaustiva en tiempo de compilación a través de sus parámetros de tipo genérico, lo cual es excelente. Pero se basa en la coincidencia posicional (primer tipo, segundo tipo, etc.), las firmas genéricas se vuelven difíciles de manejar con rapidez y no se integra con la coincidencia de patrones del lenguaje. Es un truco inteligente, pero sigue siendo un truco.
Enumeración manual + patrón de datos
Algunos desarrolladores siguen la ruta clásica con una etiqueta enum y un contenedor de datos:
public enum PaymentType { CreditCard, BankTransfer, DigitalWallet }
public class Payment
{
public PaymentType Type { get; init; }
public CreditCardInfo? CreditCard { get; init; }
public BankTransferInfo? BankTransfer { get; init; }
public DigitalWalletInfo? DigitalWallet { get; init; }
}
Esto es frágil. Nada le impide configurar Type en CreditCard pero completar la propiedad BankTransfer. El compilador no puede ayudarle y terminará con errores de tiempo de ejecución y comprobaciones nulas en todas partes. Es el enfoque “tipado en cadena” para el modelado de tipos, y no escala.
Todos estos enfoques comparten un problema fundamental: están luchando contra el lenguaje en lugar de trabajar con él. El compilador no puede razonar sobre el conjunto cerrado de posibilidades, por lo que se pierde la propiedad más valiosa de las uniones discriminadas: la verificación exhaustiva.
La propuesta de C#: la palabra clave union
La propuesta del equipo de lenguaje C# introduce una palabra clave union dedicada que define un conjunto cerrado de miembros con nombre, cada uno de los cuales transporta datos opcionalmente. Aquí está la sintaxis básica tal como se ha propuesto:
union Shape
{
Circle(double Radius),
Rectangle(double Width, double Height),
Triangle(double Base, double Height)
}
Eso es todo. Limpio, conciso y de lectura inmediata. Cada miembro dentro de la unión define una variante distinta con sus propios datos. El compilador sabe que Shape solo puede ser una de estas tres cosas.
Debajo del capó, el compilador genera una jerarquía de tipos sellada, similar a la que escribirías a mano con registros abstractos, pero con pleno conocimiento del compilador de la naturaleza cerrada del tipo. Esto significa que el compilador puede imponer exhaustividad en la coincidencia de patrones, que es el beneficio clave.
Miembros de valor exclusivo
Los miembros del sindicato no tienen que llevar datos. Puede mezclar miembros que transportan datos con miembros de valor simple:
union Option<T>
{
Some(T Value),
None
}
Este es el tipo clásico Option/Maybe que los programadores funcionales han estado pidiendo en C# durante años. None no contiene datos, es solo una etiqueta.
Uniones genéricas
Como puede ver en el ejemplo Option<T> anterior, los sindicatos admiten genéricos. Aquí hay un ejemplo más complicado:
union Result<T, E>
{
Ok(T Value),
Error(E Err)
}
Esto abre todo un estilo de manejo de errores que no depende de excepciones para casos de falla esperados, algo que ha sido una práctica estándar en Rust y los lenguajes funcionales durante años.
Uniones con métodos
La propuesta también permite que las uniones tengan métodos, propiedades calculadas e implementen interfaces, como cualquier otro tipo:```csharp union Shape { Circle(double Radius), Rectangle(double Width, double Height), Triangle(double Base, double Height);
public double Area => this switch
{
Circle(var r) => Math.PI * r * r,
Rectangle(var w, var h) => w * h,
Triangle(var b, var h) => 0.5 * b * h
};
public double Perimeter => this switch
{
Circle(var r) => 2 * Math.PI * r,
Rectangle(var w, var h) => 2 * (w + h),
Triangle(var b, var h) => b + h + Math.Sqrt(b * b + h * h)
};
}
Observe cómo la expresión `switch` dentro de `Area` y `Perimeter` no necesita un brazo predeterminado. El compilador sabe que la unión es exhaustiva: sólo hay tres variantes y se manejan las tres. Si agrega una cuarta variante más adelante, el compilador marcará cada `switch` que no la maneje.
## Integración de coincidencia de patrones
La coincidencia de patrones ha estado evolucionando en C# desde la versión 7.0 y los tipos de unión están diseñados para ser ciudadanos de primera clase de ese sistema.
### Expresiones de cambio exhaustivas
La característica más impactante es la verificación exhaustiva de los interruptores. Con las uniones, el compilador **conoce** todos los casos posibles:
```csharp
string Describe(Shape shape) => shape switch
{
Circle(var r) => $"A circle with radius {r}",
Rectangle(var w, var h) => $"A {w}x{h} rectangle",
Triangle(var b, var h) => $"A triangle with base {b} and height {h}"
};
Sin brazo de descarte. Sin _ => throw new NotImplementedException(). Si olvida un caso, el compilador emite un error, no una advertencia. Se trata de una mejora fundamental en materia de seguridad.
Coincidencia de patrones anidados
Las uniones se componen naturalmente con patrones anidados:
union Expr
{
Literal(double Value),
Add(Expr Left, Expr Right),
Multiply(Expr Left, Expr Right),
Negate(Expr Inner)
}
double Evaluate(Expr expr) => expr switch
{
Literal(var v) => v,
Add(var left, var right) => Evaluate(left) + Evaluate(right),
Multiply(var left, var right) => Evaluate(left) * Evaluate(right),
Negate(var inner) => -Evaluate(inner)
};
Este tipo de estructura de datos recursiva es extremadamente común en compiladores, intérpretes, motores de reglas y modelos matemáticos. Hoy en C# necesitarías una jerarquía de clases profunda y el patrón de visitante. Con los sindicatos, el código es muchísimo más sencillo.
Cláusulas de protección
La coincidencia de patrones con uniones admite when guardias tal como era de esperar:
string Classify(Shape shape) => shape switch
{
Circle(var r) when r > 100 => "Large circle",
Circle(var r) when r > 10 => "Medium circle",
Circle(_) => "Small circle",
Rectangle(var w, var h) when w == h => "Square",
Rectangle _ => "Rectangle",
Triangle _ => "Triangle"
};
Ejemplos prácticos
Permítanme analizar algunos escenarios del mundo real donde los tipos de unión mejoran drásticamente el código.
El patrón de resultados: reemplazar excepciones por errores esperados
Uno de los patrones más comunes en el desarrollo de aplicaciones modernas es representar operaciones que pueden tener éxito o fallar sin utilizar excepciones para el flujo de control. Las excepciones deben ser excepcionales: cosas como fallas de red o condiciones de falta de memoria. Un error de validación o un resultado “no encontrado” es un resultado esperado, no una excepción.
union Result<T, E>
{
Ok(T Value),
Error(E Err)
}
union OrderError
{
NotFound(Guid OrderId),
InsufficientStock(string ProductId, int Requested, int Available),
PaymentDeclined(string Reason),
ValidationFailed(IReadOnlyList<string> Errors)
}
Result<Order, OrderError> ProcessOrder(OrderRequest request)
{
if (!Validate(request, out var errors))
return new ValidationFailed(errors);
var product = catalog.Find(request.ProductId);
if (product is null)
return new NotFound(request.OrderId);
if (product.Stock < request.Quantity)
return new InsufficientStock(request.ProductId, request.Quantity, product.Stock);
var paymentResult = paymentGateway.Charge(request.Payment);
if (!paymentResult.Success)
return new PaymentDeclined(paymentResult.Message);
var order = CreateOrder(request, product);
return new Ok(order);
}
Luego, la persona que llama se ve obligada a manejar todos los resultados posibles:
var result = ProcessOrder(request);
var response = result switch
{
Ok(var order) => Results.Created($"/orders/{order.Id}", order),
Error(NotFound(var id)) => Results.NotFound($"Order {id} not found"),
Error(InsufficientStock(var pid, var req, var avail)) =>
Results.Conflict($"Product {pid}: requested {req}, only {avail} available"),
Error(PaymentDeclined(var reason)) =>
Results.UnprocessableEntity($"Payment declined: {reason}"),
Error(ValidationFailed(var errors)) =>
Results.BadRequest(new { Errors = errors })
};
Sin bloques try-catch, sin tipos de excepción olvidados, sin sorpresas en tiempo de ejecución. Cada modo de falla es visible en la firma de tipo y el compilador lo aplica. Esta es una mejora enorme para la confiabilidad de la API.
Modelado de dominio: tipos de pago
A continuación se muestra un ejemplo de modelado de dominio del mundo real: manejo de diferentes métodos de pago en un sistema de comercio electrónico:
union PaymentMethod
{
CreditCard(string CardNumber, string Expiry, string Cvv),
BankTransfer(string Iban, string Bic),
DigitalWallet(string Provider, string Token),
CashOnDelivery
}
decimal CalculateProcessingFee(PaymentMethod method, decimal amount) => method switch
{
CreditCard _ => amount * 0.029m + 0.30m,
BankTransfer _ => 1.50m,
DigitalWallet(provider: "PayPal", _) => amount * 0.034m + 0.35m,
DigitalWallet _ => amount * 0.025m,
CashOnDelivery => 4.99m
};
string FormatForReceipt(PaymentMethod method) => method switch
{
CreditCard(var num, _, _) => $"Credit Card ending in {num[^4..]}",
BankTransfer(var iban, _) => $"Bank Transfer ({iban[..4]}****)",
DigitalWallet(var provider, _) => $"Digital Wallet ({provider})",
CashOnDelivery => "Cash on Delivery"
};
Compare esto con el enfoque actual donde tendría una interfaz o clase abstracta con cinco implementaciones diferentes distribuidas en cinco archivos, posiblemente con un patrón de visitante en la parte superior. El enfoque de unión mantiene la definición de datos y las operaciones juntas, legibles y verificadas exhaustivamente.
Máquinas de estados
Las máquinas de estado están en todas partes del software: procesamiento de pedidos, motores de flujo de trabajo, gestión de conexiones, estado de la interfaz de usuario. Los sindicatos los hacen explícitos y seguros:
union ConnectionState
{
Disconnected,
Connecting(string Host, int Port, DateTime StartedAt),
Connected(string Host, int Port, TcpClient Client),
Reconnecting(string Host, int Port, int Attempt, TimeSpan Delay),
Failed(string Host, Exception Error)
}
ConnectionState HandleEvent(ConnectionState state, ConnectionEvent evt) => (state, evt) switch
{
(Disconnected, Connect(var host, var port)) =>
new Connecting(host, port, DateTime.UtcNow),
(Connecting(var h, var p, _), ConnectionSucceeded(var client)) =>
new Connected(h, p, client),
(Connecting(var h, var p, _), ConnectionFailed(var ex)) =>
new Reconnecting(h, p, Attempt: 1, Delay: TimeSpan.FromSeconds(1)),
(Reconnecting(var h, var p, var attempt, _), ConnectionSucceeded(var client)) =>
new Connected(h, p, client),
(Reconnecting(var h, var p, var attempt, _), ConnectionFailed(var ex)) when attempt >= 5 =>
new Failed(h, ex),
(Reconnecting(var h, var p, var attempt, _), ConnectionFailed(_)) =>
new Reconnecting(h, p, attempt + 1, TimeSpan.FromSeconds(Math.Pow(2, attempt))),
(Connected(_, _, var client), Disconnect) =>
new Disconnected,
_ => state // Ignore events that don't apply to current state
};
Cada estado lleva exactamente los datos relevantes para ese estado. No puedes acceder accidentalmente a un TcpClient cuando estás en el estado Connecting porque no existe en esa variante. El sistema de tipos impone las invariantes de la máquina de estados.
Consideraciones de serialización e interoperabilidadUna de las preguntas prácticas que surge inmediatamente con los tipos de sindicatos es: ¿cómo se serializan? Si está creando API o almacenando datos, necesita la serialización JSON para funcionar correctamente.
El equipo de diseño ha estado analizando la compatibilidad integrada con System.Text.Json para tipos de unión. El enfoque esperado implica serializar con una propiedad discriminadora:
{
"$type": "Circle",
"radius": 5.0
}
{
"$type": "Rectangle",
"width": 10.0,
"height": 20.0
}
Esto es consistente con el soporte de serialización polimórfica existente introducido en .NET 7 con atributos JsonDerivedType. La expectativa es que los sindicatos funcionen con System.Text.Json listo para usar, usando el nombre de la variante como discriminador de tipo de forma predeterminada.
Para Entity Framework Core, el enfoque probable es almacenar valores de unión utilizando una columna discriminadora, similar a cómo ya funciona el mapeo de herencia de tabla por jerarquía (TPH). La integración exacta de EF Core aún se está diseñando, pero ya existe la infraestructura para manejar jerarquías de tipos cerrados.
Vale la pena señalar que la interoperabilidad con otros lenguajes .NET debería ser fluida, ya que las uniones se compilarán en jerarquías de clases IL estándar bajo el capó. El código de F# que consume una unión de C# lo vería como una jerarquía de tipos estándar y viceversa.
Cómo probarlo
Al momento de escribir este artículo, los tipos de unión están disponibles como una característica de vista previa en las últimas vistas previas del SDK de .NET. Para experimentar con la sintaxis propuesta, necesitará:
- Instale el SDK de vista previa de .NET más reciente
- Habilite la versión de idioma de vista previa en su archivo de proyecto:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>
</Project>
Tenga en cuenta que las funciones de vista previa están sujetas a cambios. La sintaxis, el comportamiento y los diagnósticos del compilador pueden evolucionar significativamente antes del lanzamiento final. No envíe código de producción basándose en funciones de lenguaje de vista previa, pero experimente con ellas y brinde comentarios. El equipo de C# monitorea activamente las discusiones del repositorio csharplang.
Si desea seguir el progreso de la propuesta, los lugares clave a seguir son:
- El repositorio dotnet/csharplang para debates sobre diseño de lenguajes.
- El repositorio dotnet/roslyn para el progreso de la implementación del compilador
- El blog .NET para anuncios oficiales.
Comparación con enfoques existentes
Permítanme hacer una comparación rápida para que puedan ver cómo se comparan los diferentes enfoques:
| Característica | Registros abstractos | UnoDe<T1,T2> | Enumeración + Datos | Tipos de unión |
|---|---|---|---|---|
| Control de exhaustividad | ❌ No | ✅ Sí | ❌ No | ✅ Sí |
| Coincidencia de patrones | ✅ Sí | ❌ Limitado | ❌Manual | ✅ Nativo |
| Cierre impuesto por el compilador | ❌ No | ✅ Sí | ❌ No | ✅ Sí |
| Datos por variante | ✅ Sí | ✅ Sí | ⚠️ Frágil | ✅ Sí |
| Legibilidad | ⚠️ Detallado | ⚠️ Posicional | ❌ Pobre | ✅ Excelente |
| Serialización | ✅Manuales | ⚠️ Complejo | ✅Manuales | ✅ Incorporado |
| Repetitivo | ⚠️ Moderado | ✅ Bajo | ⚠️ Alto | ✅ Mínimo |
| Sin dependencia externa | ✅ Sí | ❌ NuGet | ✅ Sí | ✅ Sí |
Qué significa esto para el ecosistema .NET
La introducción de uniones discriminadas se extenderá por todo el ecosistema .NET. Esto es lo que espero ver:
El diseño de la biblioteca mejorará. Las API que actualmente devuelven null para indicar “no encontrado” o arrojan excepciones por fallas de validación podrán devolver tipos Result<T, E> en su lugar. Esto hace que los modos de falla sean explícitos en la firma de tipo: puede ver qué puede salir mal mirando la firma del método, no leyendo la documentación o el código fuente.
El modelado de dominio se vuelve más expresivo. La brecha entre el dominio del problema y la representación del código se reduce dramáticamente. Cuando su experto en el dominio dice “un pago puede ser una tarjeta de crédito, una transferencia bancaria o contra reembolso”, puede modelarlo directamente como una unión en lugar de traducirlo a una jerarquía de herencia.
Las ideas de F# se vuelven accesibles para los desarrolladores de C#. Muchos desarrolladores de C# han admirado el sistema de tipos de F# desde la distancia, pero no han podido adoptar F# en sus organizaciones. Los tipos de unión aportan una de las características más poderosas de F# a C#, lo cual es una ventaja para todo el ecosistema .NET.
Menos errores de tiempo de ejecución. La comprobación exhaustiva por sí sola evitará categorías enteras de errores. Cada vez que agrega una nueva variante a una unión, el compilador lo guiará a cada lugar del código base que necesite actualización. No más casos switch olvidados, no más NotImplementedException en ramas predeterminadas que solo aparecen en producción.
Conclusión
Los sindicatos discriminados han estado en lo más alto de la lista de deseos de la comunidad de C# durante casi una década, y con razón. Resuelven una brecha fundamental en el sistema de tipos: la capacidad de modelar datos que pueden ser “una de varias cosas” con seguridad impuesta por el compilador.
La palabra clave union propuesta brinda una sintaxis limpia y concisa que se integra profundamente con la coincidencia de patrones de C#, funciona con genéricos, admite métodos e interfaces y permite una verificación exhaustiva que detecta errores en tiempo de compilación en lugar de en tiempo de ejecución.
Ya sea que esté creando modelos de dominio, diseñando API con tipos de error explícitos, implementando máquinas de estado o simplemente tratando de reemplazar jerarquías de herencia incómodas con algo más natural, los tipos de unión cambiarán la forma en que escribe C#.
Hemos estado esperando esto durante mucho tiempo. La sintaxis es elegante, la integración con las características del lenguaje existente es cuidadosa y el impacto práctico se sentirá en todo el ecosistema .NET.
Esté atento a las versiones preliminares, experimente con la función y proporcione comentarios al equipo de diseño del lenguaje. Esta es una de esas funciones que, una vez que la hayas usado, te preguntarás cómo pudiste vivir sin ella.