Todos hemos tenido que lidiar con situaciones en las que una función o método puede devolver diferentes tipos de datos dependiendo de ciertas condiciones. El ejemplo clásico es una función que puede devolver un resultado exitoso o un error, lo que a menudo se maneja con tipos como Result<T> o haciendo algunos malabarismos con composiciones de clases o herencia.
var result = GetUser(1);
if(result.Success)
{
var user = result.Value;
// Hacer algo con el usuario
}
else
{
var error = result.Error;
// Manejar el error
}
Son artificios que, aunque funcionan, dan trabajo, complican la legibilidad y ocultan la intención real del desarrollador a la hora de escribir código de procedimientos que manejen múltiples tipos de retorno.
Los tipos unión son una de las características más solicitadas en C# durante años, y finalmente los tendremos disponibles en la versión 15 del lenguaje, que vendrá de la mano de NET 11 en noviembre de 2026.
Con esta nueva capacidad, podremos crear tipos que pueden contener valores de varios tipos diferentes, con controles en tiempo de compilación y sin apenas complejidad adicional.
Lo vemos en detalle a continuación.
¿Qué son y cómo se usan los tipos unión?
Un tipo unión es un tipo de datos que puede contener valores de diferentes tipos. Un ejemplo clásico es el escenario que mostrábamos antes, donde una función puede devolver un resultado exitoso o un error. Con los tipos unión, podríamos definir un tipo que puede contener uno de los dos de manera más natural y legible.
En C#, un tipo unión se implementa mediante la palabra clave union, que permite definir un tipo de datos cuyas instancias pueden contener valores de uno de los tipos que se hayan especificado. Uf, es difícil de describir, pero seguro lo veremos más claro con unos ejemplos.
Podríamos definir un tipo unión para representar el resultado de una operación que puede devolver un usuario o un error:
public union GetUserResult (User, Error);
También podríamos usarlo para agrupar diferentes tipos de vehículos en un solo tipo, sin necesidad de crear abstracciones, como una jerarquía de clases o interfaces:
public union Vehicle (Car, Bike, Truck);
También podríamos representar un identificador que podría ser un número entero, una cadena o un GUID:
public union ItemIdentifier (int, string, Guid);
Como podréis intuir, no hay ningún límite. El tipo unión puede contener cualquier combinación de tipos, incluso tipos personalizados o tipos genéricos, y que no tengan ninguna relación entre ellos.
Uso de tipos unión
Una vez creado el tipo unión, podemos usarlo para declarar variables y asignarles valores de cualquiera de los tipos que lo componen, porque el lenguaje permitirá la conversión implícita:
ItemIdentifier id = 123; // Puede ser un entero
id = "abc"; // O una cadena
id = Guid.NewGuid(); // O un GUID
Aunque obviamente, no podremos hacerlo al revés. La conversión implícita solo funciona en un sentido, de los tipos incluidos en la unión hacia el tipo unión:
ItemIdentifier id = 123; // Correcto
int intId = id; // Error de compilación, no se puede convertir porque
// no sabemos el tipo de datos que contiene la variable id
También, dado que se trata de un tipo de datos normal, podremos utilizarlo para enviar argumentos o retornar valores de funciones o métodos, como vemos en el siguiente ejemplo:
public union GetUserResult (User, Error);
...
public GetUserResult GetUser(int userId)
{
... // Retorna un objeto User o un objeto Error
}
Fijaos que en este caso, podríamos estar tentados a retornar un object, pero entonces no tendríamos ningún control en tiempo de compilación, el código sería propenso a errores en tiempo de ejecución y menos legible, porque un consumidor nunca podría deducir qué tipos de datos puede esperar en el retorno. Usando tipos unión, se realizan comprobaciones en compilación, de modo que si desde GetUser() intentásemos retornar algo que no sea User o Error, fallará al compilar, y leyendo la firma del método quedarán claras nuestras intenciones.
Una vez tenemos un valor de tipo unión, la forma de uso habitual será realizar comparaciones de tipo para poder acceder a su valor y procesarlo adecuadamente. Lo normal en estos casos será utilizar pattern matching, como en el siguiente ejemplo:
ItemIdentifier id = GetItemIdentifier(); // Retorna el tipo unión ItemIdentifier
if(id is int intId)
{
Console.WriteLine($"El identificador es un entero: {intId}");
}
else if(id is string strId)
{
Console.WriteLine($"El identificador es una cadena: {strId}");
}
else if(id is Guid guidId)
{
Console.WriteLine($"El identificador es un GUID: {guidId}");
}
Sin embargo, lo más habitual será utilizar expresiones switch para manejar los diferentes casos, como en el siguiente ejemplo:
ItemIdentifier id = GetItemIdentifier(); // Retorna el tipo unión ItemIdentifier
Console.WriteLine(id switch
{
int intId => $"El identificador es un entero: {intId}",
string strId => $"El identificador es una cadena: {strId}",
Guid guidId => $"El identificador es un GUID: {guidId}"
});
La ventaja de esta sintaxis, además de ser concisa, es que el compilador puede verificar que se han manejado todos los casos posibles, y si no es así, nos avisará con un warning de compilación. Por ejemplo, si olvidamos manejar el caso de Guid, el compilador nos avisará de que no se ha manejado ese tipo:
warning CS8509: The switch expression does not handle all possible values of its input type
(it is not exhaustive). For example, the pattern 'System.Guid' is not covered.
Como veremos más adelante, los tipos unión generan un struct que cumple una serie de requisitos. Pero como estructura que es, también podemos añadirle métodos, propiedades o incluso operadores personalizados. Podemos observarlo en el siguiente ejemplo, donde definimos un tipo llamado SingleOrMany<T> que puede contener un solo valor del tipo T o una colección de Ts, y que además tiene un método para contar el número de elementos que contiene y otro para obtener los elementos como enumeración:
SingleOrMany<int> value;
value = 5;
Console.WriteLine($"Count: {value.Count()}");
Console.WriteLine($"Elements: {string.Join(", ", value.AsEnumerable())}");
value = new[] { 1, 2, 3, 4 };
Console.WriteLine($"Count: {value.Count()}");
Console.WriteLine($"Elements: {string.Join(", ", value.AsEnumerable())}");
public union SingleOrMany<T> (T, IEnumerable<T>)
{
public int Count()
{
if (Value is T) return 1;
if (Value is IEnumerable<T> collection) return collection.Count();
return 0;
}
public IEnumerable<T> AsEnumerable()
{
return Value switch
{
T single => [single],
IEnumerable<T> collection => collection,
_ => []
};
}
}
¿Cómo funciona esto por debajo?
Para poder entender bien esta característica, es importante saber cómo funciona bajo el capó. Vamos a verlo partiendo del tipo ItemIdentifier que hemos definido algo más arriba:
public union ItemIdentifier (int, string, Guid);
A partir de esta declaración, el compilador de C# se encargará de crear una estructura llamada ItemIdentifier, decorada con el atributo [Union] y que implementa la interfaz IUnion. El contrato de la interfaz solo obliga a tener una propiedad de lectura Value de tipo object?:
public interface IUnion
{
object? Value { get; }
}
Esto implica que, en cualquier tipo unión, podremos acceder a la propiedad Value para obtener el object que contiene:
ItemIdentifier id = 123;
object? value = id.Value; // value es un object? que contiene el entero 123
Además de implementar dicha interfaz, la estructura generada proporciona constructores públicos para cada uno de los tipos que forman la unión, de forma que se puedan crear instancias de ItemIdentifier a partir de cualquiera de sus valores. El código completo generado por el compilador para el tipo unión ItemIdentifier sería algo así:
[Union]
public struct ItemIdentifier : IUnion
{
public object? Value { get; }
public ItemIdentifier(int value) => this.Value = (object) value;
public ItemIdentifier(string value) => this.Value = (object) value;
public ItemIdentifier(Guid value) => this.Value = (object) value;
}
Fijaos que en el código no aparecen los operadores de conversión implícita; las conversiones se realizan directamente a través del lenguaje, usando los constructores:
ItemIdentifier id = 123; // Se llama al constructor ItemIdentifier(int value)
El código que hemos visto arriba es el que genera el compilador por nosotros para los tipos unión declarados en C#, pero nosotros podemos crear tipos unión personalizados siguiendo este mismo patrón manualmente. Basta con decorar una clase, estructura o registro con el atributo [Union], implementar la interfaz IUnion y proporcionar constructores públicos para cada uno de los tipos que queramos incluir. Por ejemplo, el siguiente código define un tipo unión llamado Vehicle que puede contener un Car, una Bike o un Truck:
[Union]
public class Vehicle : IUnion
{
public object? Value { get; }
public Vehicle(Car value) => this.Value = (object)value;
public Vehicle(Bike value) => this.Value = (object)value;
public Vehicle(Truck value) => this.Value = (object)value;
}
Esta posibilidad puede ser útil en escenarios donde queramos tener un control total sobre la implementación del tipo unión, o cuando queramos incluir lógica adicional en los constructores o en la propiedad Value. Por ejemplo, dado que por defecto se utiliza un object para almacenar el valor de la unión, podríamos querer crear una implementación personalizada que evite el sobrecoste del boxing de tipos de valor.
¿Cómo puedo probar los tipos unión?
Para poder probar esta nueva característica del lenguaje a día de hoy, lo más sencillo es instalar la última versión de .NET 11 Preview, y habilitar las características preliminares de C# 15. Para ello, basta con añadir el siguiente elemento al archivo .csproj de nuestro proyecto:
<PropertyGroup>
<LangVersion>preview</LangVersion>
</PropertyGroup>
En noviembre de 2026, cuando se lance oficialmente .NET 11, esta configuración ya no será necesaria, y podremos usar los tipos unión sin necesidad de habilitar nada, simplemente compilando con el SDK de .NET 11.
Hay que tener en cuenta que se trata de una característica del lenguaje, por lo que en realidad podría utilizarse en cualquier proyecto .NET, independientemente de su target framework, siempre que se compile con el SDK de .NET 11. Eso sí, en algunas preview tempranas de .NET 11 habrá que crear manualmente la interfaz IUnion y el atributo Union porque no estarán disponibles de forma nativa en las bibliotecas de clases de .NET.
En resumen
Los tipos unión son una nueva característica de C# 15 que nos permiten definir tipos de datos que pueden contener valores de diferentes tipos, con controles en tiempo de compilación y sin complicaciones adicionales.
Esta capacidad nos ayudará a escribir código más limpio, legible y seguro, especialmente en escenarios donde una función o método puede devolver diferentes tipos de resultados.
Referencias:
- https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union
- https://devblogs.microsoft.com/dotnet/csharp-15-union-types/


Aún no hay comentarios, ¡sé el primero!
Enviar un nuevo comentario