Recientemente se ha celebrado la Microsoft Build 2026, una conferencia donde Microsoft presenta lo último de lo último en desarrollo.
Una de estas novedades fueron los Union Types, que serán soportados en C# 15. C# 15 está soportado en .NET 11, que en este momento se puede descargar como preview, es decir, todavía no es una versión estable.
Una vez descargado, creamos un proyecto estándar con:
$ mkdir unions
$ cd unions
$ dotnet new console
Aunque estamos muy cerca, aún no podemos utilizar uniones. Para poder hacerlo, tendremos que editar el archivo de proyecto (unions.csproj), de manera que incluya <LangVersion>preview</LangVersion>, como se puede ver a continuación.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Vamos a definir ahora dos clases. En la mayoría de ejemplos, se utilizan record class, y esto es muy conveniente, son muy útiles. Pero para entenderlo bien, vamos a crear dos clases normales.
public class Person {
public required string Name { get; init; }
public override string ToString() => this.Name;
}
public class Employee {
public required string Name { get; init; }
public required string Email { get; init; }
public override string ToString() => $"{this.Name} ({this.Email})";
}
Sería muy típico hacer que Employee derivase de Persona, pero como podemos ver, aunque incluso comparten una propiedad común, esto no es así. Es precisamente un caso en el que podemos crear una union.
public union Persona(Person, Employee);
...y ahora ya podemos crear una lista de elementos Person, o Employee.
var p1 = new Person{ Name = "Baltasar" };
var p2 = new Employee{ Name = "Baltasar", Email = "baltasarq@gmail.com" };
IList<Persona> l = [ p1, p2 ];
Si recorremos la lista, y pretendemos llamar al método ToString(), nos encontramos con que lo que se muestra por pantalla es lo siguiente:
Persona
Persona
Se está llamando al método ToString() de la unión, en lugar de cada uno de los métodos de los objetos pertenecientes a ella. No hay ningún tipo de method forwarding, es decir, de reenvío automático, de llamadas a métodos o propiedades, aunque existan en ambos tipos de la unión. Así, si queremos solucionar esto...
public union Persona(Person, Employee) {
public override string ToString() => this switch {
Person p => p.ToString(),
Employee e => e.ToString()
};
}
Efectivamente, tenemos que hacer pattern matching, una asociación de tipos, incluso para un caso tan sencillo como este. Podemos hacer algo más automático, como sigue:
public union Persona(Person, Employee) {
public object Active => this switch {
Person p => p,
Employee e => e
};
public override string ToString() => this.Active.ToString();
}
Así, podemos simular el reenvío automático de una manera más cómoda, solo llamando a la propiedad Active, que observemos que devuelve object.
¿Y si lo que queremos es, simplemente, crear un identificador común para ambos casos? En el caso de un objeto Person, sería el nombre en minúsculas, y en el caso de Employee, el email en minúsculas.
public union Persona(Person, Employee) {
public string Id => this switch {
Person p => p.Name.Trim().ToLower(),
Employee e => e.Email.Trim().ToLower(),
};
}
La mayor ventaja de esto es que el compilador nos avisa del caso en el que el switch no es exhaustivo, es decir, no cubre todos los posibles tipos que pueden coexistir dentro de la unión. En ese caso, nos lanzaría un aviso durante la compilación.
Hay que tener en cuenta que las uniones son siempre discriminativas. Es decir, o contienen un objeto de un tipo o del otro. Esto choca con lo que se ha dicho de "está pensado para los métodos que se quiere que se devuelvan dos valores". Esto solo es cierto si se trata de un valor u otro, en otro caso, tendremos que utilizar una tupla, o quizás una lista.
Es en este matiz donde entra la programación funcional, de donde principalmente se ha obtenido la idea para este nuevo tipo. En este tipo de programación, es típico tener un resultado que puede contener un valor o un error.
public union Result<T>(T, Exception);
De esta manera, podemos gestionar errores a la Rust, lenguaje de programación que no soporta manejo de excepciones, sino que se maneja con retornos que pueden ser correctos o contener un estado de error. Por ejemplo, la función panic(msg) toma uno de estos resultados, y en caso de error muestra el mensaje msg por pantalla y termina el programa inmediatamente.
Bueno, lo primero de todo es lograr que se lance una excepción o se devuelva el valor correcto.
public union Result<T>(T, Exception) {
public T Get() => this switch {
Exception e => throw e,
T t => t
};
}
De esta manera, cuando intentemos obtener con Get() el valor obtenido de una función, en caso de que se trate de una excepción esta se lanza inmediatamente.
El método Panic(msg: string): object, es mucho más tradicional.
public union Result<T>(T, Exception) {
public T Get() => this switch {
Exception e => throw e,
T t => t
};
public object Panic(string msg)
{
object? toret = null;
switch (this) {
case Exception e:
throw new Exception( msg, e );
default:
toret = this.Get();
break;
}
return toret;
}
}
Con esta unión podemos crear código como el siguiente:
class ... {
// Más cosas...
public Result<string> ReadFile(string fn)
{
Result<string> toret;
try {
toret = File.ReadAllText( fn );
} catch(IOException e) {
toret = e;
}
return toret;
}
}
En caso de que se produzca una excepción, esta se devuelve, en caso contrario, se almacena el resultado en la misma uníón Result<>.
Al llamar a este método, podemos descartar rápidamente un caso de error:
var result = ReadFile( "unions.cs" ).Panic( "[ERR] while accessing file 'unions.cs'" );
Console.WriteLine( result );
No creo que este tipo de programación cuadre demasiado con un lenguje como C#, que soporta excepciones, pero desde luego, es una posibilidad más.
Top comments (0)