Union types no .NET 11 e C# 15
Os union types no .NET 11 trazem uma das funcionalidades mais aguardadas pelos desenvolvedores C# nos últimos anos: suporte nativo a unions por meio da nova palavra-chave union, introduzida no C# 15. Linguagens como F#, TypeScript e Rust já ofereciam o recurso há muito tempo.
Agora, o ecossistema .NET ganha sintaxe dedicada, suporte completo do compilador e integração total com pattern matching. Neste artigo, você vai entender o que são unions, como funcionam no C# 15, o que o compilador gera e quais customizações avançadas existem.
O que são union types no .NET 11
Union types são estruturas de dados que permitem a um único valor representar um entre vários tipos possíveis. Esse conceito é central na programação funcional e resolve um problema recorrente em sistemas orientados a objetos: modelar um resultado de naturezas distintas.
O exemplo mais clássico é o tipo Result<TSuccess, TError>, que admite dois estados. No sucesso, ele contém um valor de retorno válido, enquanto no erro, contém informações sobre a falha.
Com unions, o consumidor de uma função precisa tratar explicitamente os dois casos. Dessa forma, some a suposição implícita de que toda operação sempre terá sucesso.
Essa abordagem reduz exceções não tratadas e torna o fluxo do código mais previsível. Para uma comparação mais ampla entre as estratégias, veja o artigo sobre exceptions vs Result pattern no .NET.
Contudo, unions não se limitam ao padrão Result. Eles servem em qualquer situação em que um valor pode ser de um entre vários tipos, mesmo sem relação entre si.
A palavra-chave union no C# 15
Para ver os union types no .NET 11 em ação, considere três tipos record que representam sistemas operacionais com propriedades completamente distintas:
public record Windows(string Version);
public record Linux(string Distro, string Version);
public record MacOS(string Name, int Version);
Antes do C# 15, as alternativas para lidar com um valor de qualquer um desses tipos eram limitadas. A primeira era a herança forçada, inviável quando os tipos vêm de bibliotecas externas.
A segunda era guardar o valor em object, sem segurança de tipos. Por fim, havia o uso de um enum como discriminador manual.
Com os union types no .NET 11, a declaração se torna direta:
public union SupportedOS(Windows, Linux, MacOS);
Criando instâncias
Você pode criar instâncias de duas formas:
// Construtor explícito
SupportedOS os = new SupportedOS(new MacOS("Tahoe", 25));
// Conversão implícita (o compilador chama o construtor correto)
SupportedOS other = new MacOS("Tahoe", 25);
O acesso ao valor interno acontece pela propriedade Value, do tipo object?, que vem da interface IUnion. No entanto, a forma idiomática de consumir um union é a expressão switch.
Switch e exaustividade
Veja como o switch trata cada caso do union:
string GetDescription(SupportedOS os) => os switch
{
Windows windows => $"Windows {windows.Version}",
Linux linux => $"{linux.Distro} {linux.Version}",
MacOS macOS => $"MacOS {macOS.Name} ({macOS.Version})",
};
Dois aspectos merecem destaque nesse exemplo. Primeiro, o compilador extrai automaticamente o tipo interno do union, sem conversão manual.
Segundo, você não precisa incluir o caso de descarte (_ =>), porque o compilador garante a exaustividade. Se faltar algum tipo no switch, ele emite um aviso:
warning CS8509: The switch expression does not handle all possible values of its input type
(it is not exhaustive). For example, the pattern 'MacOS' is not covered.
Essa exaustividade é uma das contribuições mais significativas do recurso. Afinal, ela expõe em tempo de compilação cenários não tratados que poderiam virar bugs em produção.
Como habilitar union types no .NET 11
Para usar union types no .NET 11 durante a fase de preview, você precisa atender a dois requisitos. O primeiro é ter o SDK do .NET 11 instalado, e o segundo é adicionar <LangVersion>preview</LangVersion> ao arquivo de projeto:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<LangVersion>preview</LangVersion>
<TargetFrameworks>net11.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Segundo Andrew Lock, o suporte inicial à palavra-chave chegou no preview 2, mas a experiência fica mais fluida a partir do preview 4. Por isso, prefira sempre o preview mais recente disponível.
Direcionando runtimes anteriores
Um detalhe relevante: embora você precise do SDK do .NET 11 para compilar, é possível direcionar runtimes anteriores como net8.0. Isso acontece porque o suporte a unions é um recurso do compilador, e não do runtime.
Nesse caso, ou em previews mais antigos, você declara os tipos auxiliares manualmente no projeto:
#if !NET11_0_OR_GREATER
namespace System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
public sealed class UnionAttribute : Attribute;
public interface IUnion
{
object? Value { get; }
}
#endif
Andrew Lock afirma que esses tipos entraram no .NET 11 no preview 4. Já a documentação oficial de union types indica que o runtime os inclui a partir do Preview 5, então vale conferir a versão do seu SDK.
Suporte nas IDEs
Na época da publicação do recurso, o Visual Studio Preview e o VS Code com C# DevKit Insiders já ofereciam suporte inicial. O suporte do JetBrains Rider, por outro lado, ainda estava pendente.
Para acompanhar o restante das mudanças da versão, o artigo sobre o .NET 11 RC 1 e o C# 15 traz o panorama atualizado.
Como union types no .NET 11 funcionam internamente
A palavra-chave union é açúcar sintático. Na prática, o compilador a traduz para uma struct com o atributo [Union] e a implementação da interface IUnion:
using System.Runtime.CompilerServices;
[Union]
public struct SupportedOS : IUnion
{
public object? Value { get; }
public SupportedOS(Windows value) => this.Value = (object) value;
public SupportedOS(Linux value) => this.Value = (object) value;
public SupportedOS(MacOS value) => this.Value = (object) value;
}
A estrutura gerada é sempre uma struct, tem uma única propriedade Value do tipo object? e expõe um construtor para cada tipo declarado no union.
A conversão implícita em SupportedOS os = new MacOS("Tahoe", 25) não é um operador de conversão comum. Em vez disso, o compilador reescreve essa linha e chama o construtor correto.
O atributo [Union] é o elemento central que habilita os comportamentos especiais: conversões implícitas, correspondência em expressões switch e exaustividade. Sem ele, o compilador não reconhece a estrutura como union, e todos esses comportamentos deixam de funcionar.
Além disso, como os union types no .NET 11 dependem apenas desse padrão, bibliotecas existentes podem adotá-lo. Basta aplicar [Union] e implementar IUnion em tipos que já modelam unions.
Evitando boxing com union types no .NET 11
A implementação padrão de um union guarda o valor interno como object?. Para tipos de valor (struct), isso implica boxing, ou seja, alocação no heap.
Considere o seguinte union:
public union IntOrBool(int, bool);
O código gerado faz boxing ao armazenar qualquer um dos valores:
[Union]
public struct IntOrBool : IUnion
{
public object? Value { get; }
public IntOrBool(int value) => this.Value = (object) value;
public IntOrBool(bool value) => this.Value = (object) value;
}
O padrão de acesso sem boxing
Para cenários de alta performance, o C# 15 permite implementar o padrão de acesso sem boxing. O requisito é expor a propriedade HasValue, do tipo bool, e um método TryGetValue para cada tipo do union:
[Union]
public struct IntOrBool : IUnion
{
private readonly bool _isBool;
private readonly int _value;
public IntOrBool(int value)
{
_isBool = false;
_value = value;
}
public IntOrBool(bool value)
{
_isBool = true;
_value = value ? 1 : 0;
}
public bool HasValue => true;
public bool TryGetValue(out int value)
{
value = _value;
return !_isBool;
}
public bool TryGetValue(out bool value)
{
value = _isBool && _value is 1;
return _isBool;
}
public object Value => _isBool ? _value is 1 : _value;
}
Com essa implementação, o compilador usa os métodos TryGetValue nas expressões switch, em vez de acessar a propriedade Value. O código resultante do lowering fica assim:
IntOrBool unmatchedValue = new IntOrBool(23);
if (unmatchedValue.TryGetValue(out int _))
{
// inteiro
}
else if (unmatchedValue.TryGetValue(out bool _))
{
// booleano
}
Nenhuma alocação no heap ocorre nessa versão. Portanto, o padrão serve bem para código de performance crítica com union types no .NET 11.
Na dúvida, comece pela declaração simples com union e meça o impacto antes de partir para a versão customizada. Em código de domínio comum, o custo do boxing raramente aparece no profiler.
Segundo a documentação, TryGetValue atende aos padrões de tipo, enquanto HasValue atende ao padrão null. Quando um deles falta, o compilador volta a consultar a propriedade Value para aquele tipo de padrão.
Pattern matching com union types no .NET 11
O pattern matching com union types no .NET 11 vai além do switch simples. O compilador aplica os padrões ao conteúdo do union, ou seja, à propriedade Value, exceto nos padrões var, _ e not:
// Captura o union completo
if (GetPet() is var pet) { /* ... */ }
// Testa o conteúdo (Value)
if (GetPet() is Dog dog) { /* ... */ }
if (GetPet() is null) { /* ... */ }
Quando os tipos do union incluem tipos anuláveis, o null passa a ser um caso adicional nas expressões switch. Nesse cenário, o compilador emite aviso se o estado de nulidade de Value for "possivelmente nulo" e o caso null estiver ausente.
O valor default
Toda declaração de union gera implicitamente uma struct. Como o valor padrão de qualquer struct é um estado zerado, default(SupportedOS).Value vale null.
Isso significa que todo union declarado com union pode, em princípio, ter Value nulo quando criado por padrão. Então, considere esse estado no design dos consumidores.
Quando usar union types no .NET 11
A referência oficial do C# recomenda declarar um union quando um valor precisa ser exatamente um tipo de um conjunto fixo. Além disso, o compilador deve garantir que o código trate cada possibilidade.
O primeiro cenário típico é o retorno de resultado ou erro. Nesse caso, um union como union Result(Success, Error) deixa explícito o conjunto de desfechos que o chamador precisa tratar.
O segundo cenário é o despacho de mensagens ou comandos. Quando um sistema processa um conjunto fechado de mensagens, cada novo tipo gera avisos em todo switch que ainda não o trata.
O terceiro cenário é a troca de interfaces de marcação ou classes base abstratas. Se você usa uma interface só para agrupar tipos no pattern matching, o union entrega exaustividade sem exigir herança.
Diferenças para class, interface e record
Diferente de uma class ou struct, um union não define novos dados. Em vez disso, ele compõe tipos existentes em um conjunto fechado de alternativas.
Diferente de uma interface, o union é fechado, porque a declaração traz a lista completa de casos. Já em relação a um record, o union não acrescenta igualdade, clonagem nem desconstrução.
Em outras palavras, o union responde à pergunta "qual caso é este?", e não "quais campos ele tem?". Essa distinção ajuda a decidir quando usar union types no .NET 11 e quando preferir um record comum.
Union types no .NET 11 baseados em classe e anuláveis
A declaração com union sempre gera uma struct. Contudo, uma classe também pode ser um union, desde que siga o padrão básico com [Union], construtores de um parâmetro e a propriedade Value:
[System.Runtime.CompilerServices.Union]
public class Result<T> : System.Runtime.CompilerServices.IUnion
{
private readonly object? _value;
public Result(T? value) { _value = value; }
public Result(Exception? value) { _value = value; }
public object? Value => _value;
}
Esse formato de union types no .NET 11 serve quando você precisa de semântica de referência ou de herança. Nos unions baseados em classe, o padrão null casa tanto com uma referência nula quanto com um Value nulo.
Por outro lado, a conversão implícita também funciona para a versão anulável de um union struct. Assim, um SupportedOS? aceita diretamente um MacOS ou null, e o switch ganha um braço null:
SupportedOS? maybeOs = new Linux("Ubuntu", "24.04");
string text = maybeOs switch
{
Windows w => $"Windows {w.Version}",
Linux l => $"{l.Distro} {l.Version}",
MacOS m => $"MacOS {m.Name}",
null => "no operating system",
};
Implementando Result e Option com union types no .NET 11
A nova sintaxe permite implementar o clássico Result pattern de forma nativa:
public union Result<T>(T, Exception);
Veja esse tipo em uma operação que pode falhar:
Result<string> ReadFile(string path)
{
try
{
return File.ReadAllText(path);
}
catch (Exception ex)
{
return ex;
}
}
var result = ReadFile("config.json");
string message = result switch
{
string content => $"Read succeeded: {content.Length} characters",
Exception ex => $"Error: {ex.Message}",
};
Da mesma forma, você pode implementar o tipo Option<T>:
public record class None;
public union Option<T>(None, T);
Esses exemplos mostram como os union types no .NET 11 eliminam, na maioria dos casos, a necessidade de bibliotecas de terceiros como OneOf. Se quiser ver o padrão aplicado a regras de negócio reais, o artigo sobre union types no C# 15 substituindo o Result modela um cancelamento de pedidos completo.
Funcionalidades previstas para versões futuras
Quando o recurso estreou nos previews, a especificação já incluía itens ainda indisponíveis no compilador. Três deles se destacavam no roadmap.
Union member providers
O primeiro é o conceito de union member providers. Eles permitem definir os membros de um union em uma interface aninhada, chamada IUnionMembers, em vez de diretamente no tipo.
Esse mecanismo ajuda a adaptar tipos existentes ao comportamento de union, por exemplo quando o tipo precisa de construtor privado ou de uma fábrica. A referência atual do C# já documenta esse padrão.
Closed enums e closed hierarchies
O segundo é o suporte a closed enums, que dispensa o caso de descarte em expressões switch sobre enumerações. Assim, as enums ganham o mesmo princípio de exaustividade dos unions.
O terceiro recurso são as closed hierarchies. Com o modificador closed, uma classe impede subclasses diretas fora do assembly onde foi definida.
Com isso, expressões switch sobre hierarquias de classes também ficam exaustivas, sem caso de descarte. Mais tarde, a página oficial de novidades do C# 15 passou a listar as closed hierarchies ao lado dos unions.
Junto com os union types no .NET 11, esses recursos tornam o C# mais expressivo na modelagem de dados, com exaustividade verificada em tempo de compilação.
Union types e discriminated unions
Vale reforçar uma distinção que gera confusão. Os union types no .NET 11 são unions de tipos, e não discriminated unions, também chamadas de tagged unions.
Em discriminated unions, cada caso tem uma identidade nomeada. Já em type unions, o próprio tipo identifica o caso.
Ainda assim, você simula discriminated unions com type unions usando records novos como casos. A especificação oficial descreve exatamente essa estratégia.
Conclusão
Os union types no .NET 11 representam uma evolução significativa na capacidade expressiva do C#. O recurso resolve de forma nativa um problema que antes exigia bibliotecas externas ou padrões verbosos.
Além disso, ele oferece sintaxe concisa, integração com pattern matching, verificação de exaustividade em tempo de compilação e customização para cenários de alta performance.
A separação entre a declaração simplificada via union e a implementação customizada via [Union] e TryGetValue mostra um design cuidadoso. Esse design equilibra produtividade e controle granular, e a adoção deve crescer à medida que o .NET 11 se aproxima do lançamento oficial.
FAQ
Os union types substituem bibliotecas como OneOf?
Para a maioria dos casos, sim, porque a implementação nativa integra com o compilador e garante exaustividade. Bibliotecas existentes também podem aderir implementando IUnion e aplicando [Union].
Union types têm impacto de performance?
A implementação padrão faz boxing de tipos de valor, o que gera alocação no heap. Para cenários críticos, o padrão com TryGetValue evita o boxing, e com tipos de referência o impacto é mínimo.
É possível usar union types com generics?
Sim. A sintaxe public union Result<T>(T, Exception) é válida, e os parâmetros de tipo funcionam como case types normais.
Union types funcionam em runtimes anteriores ao .NET 11?
Sim, desde que você compile com o SDK do .NET 11. Nesse caso, declare manualmente os tipos UnionAttribute e IUnion no projeto.
É possível ter mais de dois tipos em um union?
Sim. A sintaxe public union Example(TypeA, TypeB, TypeC, TypeD) é válida.
Qual a diferença entre union type e discriminated union?
No C# 15, os unions são de tipos, e o próprio tipo identifica cada caso. Em discriminated unions, cada caso tem um nome próprio, mas você pode simulá-las usando records como casos.
O JetBrains Rider oferece suporte?
Na época da publicação do recurso, ainda não. O Visual Studio Preview e o VS Code com C# DevKit Insiders já tinham suporte inicial.