Tipos Union no C# 15: Valores de um Conjunto Fechado de Tipos
Objective
Uma declaração union, nova no C# 15, nomeia um tipo cujo valor é exatamente um
de uma lista fechada de case types: union Pet(Cat, Dog, Bird);. O compilador dá
a ela uma conversão implícita de cada case type e conhece a lista, então um
switch que trata todos os case types é exaustivo sem braço _, e um que falta
vira warning. Antes do C# 15 a mesma ideia exigia uma classe base abstrata, uma
interface marcadora ou uma biblioteca como OneOf. Este conceito cobre o que a
declaração gera, como patterns leem uma union, o que default faz, os
diagnósticos, a union escrita à mão que evita boxing, o suporte do
System.Text.Json e o que acontece em target frameworks antigos. Tudo foi
compilado e executado no SDK 11.0.100.
Use Cases
- Um resultado de método que é um entre vários tipos sem relação, como um
pagamento que é
Approved,DeclinedouPending, em que o chamador precisa tratar cada um. - Despacho de mensagens ou comandos sobre um conjunto fixo de tipos que não têm classe base comum e que você pode não controlar.
- Trocar um
Result<T>feito à mão ou um genérico do OneOf por um tipo que o compilador confere (vejaresult-types-versus-exceptions). - Um corpo JSON que pode ser número ou string, mapeado para um único tipo C#.
Deep Dive
O que uma declaração gera
csharppublic record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);Reflection numa union confirma a forma. typeof(Pet).IsValueType é true, o
tipo carrega UnionAttribute e implementa IUnion, e tem um construtor público
por case type mais uma propriedade Value do tipo object?. Case types podem
ser classes, structs, interfaces, tipos anuláveis, parâmetros de tipo ou outras
unions (union Option<T>(None, Some<T>) é válido, segundo a referência). Uma
union não é um record: não adiciona igualdade, clonagem nem desconstrução, e não
adiciona dados próprios. Ela só compõe tipos que já existem.
csharpPet p = new Dog("Rex"); // a conversão implícita chama o construtor de Dog
Console.WriteLine(p.Value); // Dog { Name = Rex }Quando um case type é de valor, o valor sofre boxing no object?:
union IntOrString(int, string) guarda o int como objeto, e
IntOrString x = 42; x.Value.GetType() imprime System.Int32, lido desse
objeto. Se dois case types podem aceitar o valor de origem, o compilador escolhe
a melhor conversão pelas regras de sempre: com union U(int, long), U u = 5;
guarda um Int32 e U v = 5L; guarda um Int64. Quando nenhuma é melhor, é
erro: union U(IFormattable, IComparable) com U u = 5; dá CS0457: Ambiguous user defined conversions.
Uma declaração pode ter corpo com métodos e propriedades calculadas, mas não
campos de instância, auto-properties nem eventos field-like (CS9373), nem um
construtor público explícito com um parâmetro, porque o compilador reserva esses
como membros de criação (CS9374).
Patterns testam o conteúdo
A regra mais importante: numa union, um pattern de tipo ou de declaração testa o
conteúdo, não o valor da union. Dog d pergunta se Value é um Dog.
csharpstring name = p switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};Um pattern recursivo com tipo (Dog { Name: "Rex" }) testa o conteúdo e depois
olha dentro do Dog. Um pattern recursivo sem tipo usa os membros da própria
union, um pattern var ou de descarte captura a própria union, e null testa o
conteúdo. Por isso p is Pet não compila (CS8121): nenhum case type é Pet.
E para o valor default, os dois testes naturais discordam:
csharpPet empty = default;
Console.WriteLine(empty is not null); // False: o conteúdo é nulo
Console.WriteLine(empty is { }); // True: o struct union em si existeUse is not null para "tem valor" e um braço null num switch.
Exaustividade e o valor default
Tratar todos os case types dispensa o descarte. Deixar um de fora dá o warning que você já conhece dos enums, com o case faltante nomeado:
csharpConsole.WriteLine(p switch { Dog d => 1, Cat c => 2 });
// warning CS8509: ... the pattern 'Bird' is not covered.Um braço de descarte ou var esconde esse warning no dia em que alguém
acrescentar um quarto case type, então prefira um braço null quando precisar
tratar conteúdo vazio. É no null que unions têm uma lacuna: default(Pet) e
qualquer elemento de array ou campo do tipo Pet que nunca foi atribuído guardam
Value nulo. Para Pet, cujos case types são todos não anuláveis, o compilador
rastreia o conteúdo como "not null", então omitir o braço null não gera
warning, e o switch lança em runtime:
csharpPet p = default;
p switch { Dog d => 1, Cat c => 2, Bird b => 3 };
// SwitchExpressionException: Non-exhaustive switch expression failed to match
// its input. Unmatched value was Pet.Com um case type anulável, union NP(Cat?, Dog), o conteúdo é "maybe null" e
omitir o braço null é CS8655, um warning que o lab transforma em erro. Regra
prática: trate null sempre que um valor default possa chegar ao switch, o que
vale sempre para unions struct em arrays e campos.
Uma union escrita à mão, sem boxing
A union gerada é sempre struct e sempre faz boxing de cases de valor. Quando isso
importa, escreva a union você mesmo. Um tipo é uma union se tem [Union], um
construtor público por case type (um parâmetro) e uma propriedade pública
Value do tipo object?. O padrão de acesso sem boxing opcional acrescenta
HasValue e um TryGetValue(out T) por case type, e então o compilador casa
tipos via TryGetValue em vez de ler Value:
csharp[System.Runtime.CompilerServices.Union]
public struct IntOrText : System.Runtime.CompilerServices.IUnion
{
private readonly int _int;
private readonly string? _text;
private readonly byte _tag; // 0 = nada, 1 = int, 2 = texto
public IntOrText(int value) { _int = value; _tag = 1; }
public IntOrText(string? value) { if (value is not null) { _text = value; _tag = 2; } }
public object? Value => _tag switch { 1 => _int, 2 => _text, _ => null };
public bool HasValue => _tag != 0;
public bool TryGetValue(out int value) { value = _int; return _tag == 1; }
public bool TryGetValue(out string value) { value = _text!; return _tag == 2; }
}No lab, um switch sobre mil desses (int i => i, string s => s.Length, null => 0) alocou 0 bytes depois do aquecimento, medido com
GC.GetAllocatedBytesForCurrentThread. O contrato de uma union customizada:
Value devolve null ou um dos case types, um valor criado a partir de um case
type informa esse tipo, e HasValue e TryGetValue concordam com Value. O
compilador confia em você nisso. O mesmo atributo também permite unions baseadas
em classe, e uma interface aninhada IUnionMembers (um "union member provider")
quando os construtores precisam ficar privados; os dois estão descritos na
referência da linguagem.
System.Text.Json
O serializador do .NET 11 trata uma declaração union como um contrato de
union. Ele escreve o valor do case sem envelope e lê de volta pelo tipo do token
JSON:
csharpIntOrString n = 42, s = "hi";
JsonSerializer.Serialize(n); // 42
JsonSerializer.Serialize(s); // "hi"
JsonSerializer.Deserialize<IntOrString>("7"); // Value é o int 7
JsonSerializer.Serialize<IntOrString>(default); // null
JsonSerializer.Deserialize<IntOrString>("true"); // JsonException: nenhum case type serveO limite são cases com o mesmo formato JSON. Pet tem três cases em forma de
objeto, então JsonSerializer.Deserialize<Pet>("{\"Name\":\"R\"}") lança
JsonException: JSON value type 'Object' is ambiguous for union type 'Pet' because multiple case types can use this value type. Specify a custom type classifier to support deserialization. Um TypeClassifier em [JsonUnion] é a
forma de resolver; para contratos novos com vários cases em forma de objeto, uma
hierarquia closed com discriminador costuma servir melhor (veja
closed-classes-and-exhaustive-hierarchies). Não executei a integração com o
ASP.NET Core; o post do time do ASP.NET diz que unions funcionam como corpo em
Minimal APIs, MVC e SignalR, mas não em query string, rota, header nem form.
Target frameworks antigos
Uma union precisa de UnionAttribute e IUnion de
System.Runtime.CompilerServices, e o runtime do .NET 11 os traz. Em net10.0 a
mesma declaração é CS0518: Predefined type 'System.Runtime.CompilerServices.IUnion' is not defined mais CS0656 para o
construtor do atributo. Declarar os dois tipos no seu projeto (uma classe de
atributo que permite classes e structs, e interface IUnion { object? Value { get; } })
fez compilar em net10.0; compilei isso, mas não executei num runtime .NET 10.
No C# 14 (LangVersion 14) não há contorno: union é erro de sintaxe. Se o
serializador reconhece um atributo declarado como polyfill é algo a testar antes
de depender disso.
Trade-offs
- Boxing para cases de valor. O struct gerado guarda um
object?, entãounion IntOrString(int, string)aloca para cadaint. Em caminhos quentes, escreva a union à mão comTryGetValue, ou use uma hierarquia de classes. defaulté um buraco. Uma union struct nunca atribuída guardanulle pode passar pelo compilador. Sempre acrescente um braçonull.- Acrescentar um case type é breaking change para consumidores. Todo
switchsobre uma union pública fica não exaustivo (warning CS8509) quando você acrescenta um case. Por isso union é para conjuntos que você controla e versiona. - Union versus hierarquia closed. Unions agrupam tipos que não precisam de
base comum, inclusive tipos que você não controla (
int,string). Uma classeclosedtorna uma hierarquia exaustiva quando a base é sua. Use a union no primeiro caso e a hierarquia no segundo. - Union versus exceções e métodos
Try. Um resultado union é explícito, mas obriga o chamador a escrever umswitch. Use onde a falha é um desfecho esperado, não para erros de programação. - Cobertura do ecossistema. O System.Text.Json conhece unions no .NET 11. Para ORM, driver de banco ou mapper, confira o suporte antes de pôr uma union num modelo.