Tipos de união C#: Uniões discriminadas estão finalmente chegando

· 16 min de leitura

Se você escreve C# há algum tempo, há uma boa chance de você ter se deparado com um obstáculo ao tentar modelar algo que pode ser “uma entre várias coisas”. Talvez você precisasse de um método para retornar um valor de sucesso ou um erro. Talvez você estivesse construindo um sistema de pagamento que lida com cartões de crédito, transferências bancárias e carteiras digitais – cada uma com dados totalmente diferentes. Ou talvez você apenas olhou para F# ou Rust e pensou: “Por que não posso ter isso em C#?”

A espera está quase no fim. Sindicatos discriminados estão chegando ao C#.

Este tem sido um dos recursos linguísticos mais solicitados há anos, com discussões na comunidade que remontam a 2017 e antes. A equipe de design da linguagem C# está trabalhando em uma proposta que introduz uma palavra-chave union para definir hierarquias de tipo fechado com correspondência exaustiva de padrões. Neste post, quero explicar o que são os sindicatos discriminados, por que eles são tão importantes, como os falsificamos até agora e como realmente é a sintaxe proposta - com exemplos de código reais para cada um.

Uma observação rápida antes de começarmos: no momento em que este livro foi escrito, o recurso de tipos de união ainda estava em fase de proposta e visualização. A sintaxe e o comportamento que descrevo aqui baseiam-se nos documentos de design mais recentes disponíveis publicamente e nas discussões da equipe de design da linguagem C#. As coisas podem mudar antes do lançamento final. Serei claro sobre o que está confirmado versus o que ainda está em discussão.

O que são sindicatos discriminados?

Em sua essência, uma união discriminada (às vezes chamada de “união marcada” ou “tipo de soma”) é um tipo que pode conter um de um conjunto fixo de valores possíveis, onde cada variante pode transportar dados diferentes. A parte “discriminada” significa que o tempo de execução sempre sabe qual variante é – há uma tag que a identifica.

Pense nisso como um enum, mas onde cada membro pode carregar sua própria carga de dados.

Se você já usou outras linguagens, provavelmente já viu esse conceito antes:

F# teve sindicatos discriminados desde o primeiro dia:

type Shape =
    | Circle of radius: float
    | Rectangle of width: float * height: float
    | Triangle of base: float * height: float

Rust usa enum para a mesma ideia:

enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle { base: f64, height: f64 },
}

TypeScript consegue algo semelhante com uniões marcadas:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

Em todos esses casos, o compilador conhece todas as variantes possíveis e pode garantir que você lide com todas elas. Esse é o superpoder: verificação de exaustividade. Se você adicionar uma nova variante, o compilador informará onde você esqueceu de manipulá-la.

C# nunca teve uma maneira de primeira classe de expressar isso. Até agora.

Como simulamos sindicatos hoje

Ao longo dos anos, a comunidade C# apresentou diversas soluções alternativas, cada uma com suas próprias vantagens e desvantagens. Deixe-me examinar as abordagens mais comuns.

Registros abstratos com herança

A solução alternativa mais idiomática no C# moderno é usar registros abstratos com tipos derivados selados:

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
}

Isso funciona razoavelmente bem. Você obtém imutabilidade, igualdade de valores e pode usar correspondência de padrões:

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")
};
```Mas existem desvantagens significativas. O compilador não sabe que a hierarquia está fechada, então você sempre precisa daquele braço de descarte `_` ou receberá um aviso. Se você adicionar uma nova variante, o compilador não informará sobre todos os lugares em que você se esqueceu de manipulá-la  o descarte a engole silenciosamente. Isso anula completamente o propósito.

### A Biblioteca OneOf

Outra abordagem popular é o pacote [OneOf](https://github.com/mcintyre321/OneOf) NuGet:

```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 fornece verificação exaustiva em tempo de compilação por meio de seus parâmetros de tipo genérico, o que é ótimo. Mas depende da correspondência posicional (primeiro tipo, segundo tipo, etc.), as assinaturas genéricas tornam-se difíceis de manejar rapidamente e não se integram à correspondência de padrões da linguagem. É um hack inteligente, mas ainda é um hack.

Enum manual + padrão de dados

Alguns desenvolvedores seguem o caminho clássico com uma tag enum e um contêiner de dados:

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; }
}

Isto é frágil. Nada impede você de definir Type como CreditCard, mas preencher a propriedade BankTransfer. O compilador não pode ajudá-lo e você acaba com erros de tempo de execução e verificações de nulos em todos os lugares. É a abordagem “stringly-typed” para modelagem de tipos e não é escalonável.

Todas essas abordagens compartilham um problema fundamental: elas estão lutando contra a linguagem em vez de trabalhar com ela. O compilador não consegue raciocinar sobre o conjunto fechado de possibilidades, então você perde a propriedade mais valiosa das uniões discriminadas — a verificação exaustiva.

A proposta C#: a palavra-chave union

A proposta da equipe da linguagem C# introduz uma palavra-chave union dedicada que define um conjunto fechado de membros nomeados, cada um carregando dados opcionalmente. Aqui está a sintaxe básica conforme proposta:

union Shape
{
    Circle(double Radius),
    Rectangle(double Width, double Height),
    Triangle(double Base, double Height)
}

É isso. Limpo, conciso e imediatamente legível. Cada membro dentro da união define uma variante distinta com seus próprios dados. O compilador sabe que Shape só pode ser uma dessas três coisas.

Nos bastidores, o compilador gera uma hierarquia de tipos selada — semelhante ao que você escreveria à mão com registros abstratos, mas com total consciência do compilador sobre a natureza fechada do tipo. Isso significa que o compilador pode impor exaustividade na correspondência de padrões, que é o principal benefício.

Membros somente de valor

Os membros do sindicato não precisam transportar dados. Você pode misturar membros que transportam dados com membros de valor simples:

union Option<T>
{
    Some(T Value),
    None
}

Este é o tipo clássico Option/Maybe que os programadores funcionais vêm pedindo em C# há anos. None não carrega dados — é apenas uma tag.

Uniões Genéricas

Como você pode ver no exemplo Option<T> acima, os sindicatos apoiam os genéricos. Aqui está um exemplo mais envolvente:

union Result<T, E>
{
    Ok(T Value),
    Error(E Err)
}

Isso abre todo um estilo de tratamento de erros que não depende de exceções para casos de falha esperados – algo que tem sido uma prática padrão em Rust e em linguagens funcionais há anos.

Uniões com Métodos

A proposta também permite que os sindicatos tenham métodos, propriedades computadas e implementem interfaces, assim como qualquer outro 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 como a expressão `switch` dentro de `Area` e `Perimeter` não precisa de um braço padrão. O compilador sabe que a união é exaustiva  existem apenas três variantes e todas as três são tratadas. Se você adicionar uma quarta variante posteriormente, o compilador sinalizará todos os `switch` que não lidam com ela.

## Integração de correspondência de padrões

A correspondência de padrões tem evoluído em C# desde a versão 7.0, e os tipos de união são projetados para serem cidadãos de primeira classe desse sistema.

### Expressões de troca exaustivas

O recurso mais impactante é a verificação exaustiva dos switches. Com uniões, o compilador **conhece** todos os casos possíveis:

```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}"
};

Sem braço de descarte. Não _ => throw new NotImplementedException(). Se você esquecer um caso, o compilador emitirá um erro, não um aviso. Esta é uma melhoria fundamental na segurança.

Correspondência de padrões aninhados

As uniões são compostas naturalmente com padrões aninhados:

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)
};

Esse tipo de estrutura de dados recursiva é extremamente comum em compiladores, interpretadores, mecanismos de regras e modelagem matemática. Hoje, em C#, você precisaria de uma hierarquia de classes profunda e do padrão de visitante. Com os sindicatos, o código é dramaticamente mais simples.

Cláusulas de proteção

A correspondência de padrões com uniões oferece suporte a guardas when exatamente como seria 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"
};

Exemplos práticos

Deixe-me examinar alguns cenários do mundo real onde os tipos de união melhoram drasticamente o código.

O padrão de resultado: substituindo exceções por erros esperados

Um dos padrões mais comuns no desenvolvimento de aplicativos modernos é a representação de operações que podem ter êxito ou falhar sem usar exceções para o fluxo de controle. As exceções devem ser excepcionais – coisas como falhas de rede ou condições de falta de memória. Um erro de validação ou um resultado “não encontrado” é um resultado esperado, não uma exceção.

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);
}

O chamador é então forçado a lidar com todos os resultados possíveis:

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 })
};

Sem blocos try-catch, sem tipos de exceção esquecidos, sem surpresas em tempo de execução. Cada modo de falha é visível na assinatura de tipo e aplicado pelo compilador. Esta é uma grande melhoria para a confiabilidade da API.

Modelagem de Domínio: Tipos de Pagamento

Aqui está um exemplo de modelagem de domínio do mundo real – lidando com diferentes métodos de pagamento em um sistema de comércio eletrô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 isso com a abordagem atual, onde você teria uma interface ou classe abstrata com cinco implementações diferentes espalhadas por cinco arquivos, possivelmente com um padrão de visitante no topo. A abordagem sindical mantém a definição dos dados e as operações juntas, legíveis e verificadas exaustivamente.

Máquinas de Estado

As máquinas de estado estão por toda parte no software – processamento de pedidos, mecanismos de fluxo de trabalho, gerenciamento de conexões, estado da interface do usuário. Os sindicatos os tornam explícitos e 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 carrega exatamente os dados relevantes para esse estado. Você não pode acessar acidentalmente um TcpClient quando estiver no estado Connecting porque ele não existe nessa variante. O sistema de tipos impõe os invariantes da máquina de estados.

Considerações sobre serialização e interoperabilidadeUma das questões práticas que surgem imediatamente com os tipos de união é: como serializá-los? Se você estiver criando APIs ou armazenando dados, precisará da serialização JSON para funcionar corretamente.

A equipe de design tem discutido o suporte integrado System.Text.Json para tipos de união. A abordagem esperada envolve a serialização com uma propriedade discriminadora:

{
  "$type": "Circle",
  "radius": 5.0
}
{
  "$type": "Rectangle",
  "width": 10.0,
  "height": 20.0
}

Isso é consistente com o suporte de serialização polimórfica existente introduzido no .NET 7 com atributos JsonDerivedType. A expectativa é que os sindicatos funcionem com System.Text.Json prontos para uso, usando o nome da variante como discriminador de tipo por padrão.

Para o Entity Framework Core, a abordagem provável é armazenar valores de união usando uma coluna discriminadora — semelhante a como o mapeamento de herança de tabela por hierarquia (TPH) já funciona. A integração exata do EF Core ainda está sendo projetada, mas a infraestrutura para lidar com hierarquias de tipo fechado já existe.

Vale a pena notar que a interoperabilidade com outras linguagens .NET deve ser tranquila, já que as uniões serão compiladas em hierarquias de classe IL padrão nos bastidores. O código F# que consome uma união C# o veria como uma hierarquia de tipos padrão e vice-versa.

Como experimentar

No momento em que este artigo foi escrito, os tipos de união estavam disponíveis como um recurso de visualização nas visualizações mais recentes do SDK do .NET. Para experimentar a sintaxe proposta, você precisará:

  1. Instale o SDK de visualização .NET mais recente
  2. Habilite a versão do idioma de visualização em seu arquivo de projeto:
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>preview</LangVersion>
  </PropertyGroup>
</Project>

Lembre-se de que os recursos de visualização estão sujeitos a alterações. A sintaxe, o comportamento e o diagnóstico do compilador podem evoluir significativamente antes do lançamento final. Não envie código de produção dependendo de recursos de linguagem de visualização, mas experimente-os e forneça feedback. A equipe C# monitora ativamente as discussões do repositório csharplang.

Se você quiser acompanhar o andamento da proposta, os principais locais a serem observados são:

  • O repositório dotnet/csharplang para discussões sobre design de linguagem
  • O repositório dotnet/roslyn para o progresso da implementação do compilador
  • O blog .NET para anúncios oficiais

Comparação com abordagens existentes

Deixe-me fazer uma comparação rápida para que você possa ver como as diferentes abordagens se comparam:

RecursoRegistros AbstratosUmDe<T1,T2>Enum + DadosTipos de União
Verificação de exaustividade❌ Não✅ Sim❌ Não✅ Sim
Correspondência de padrões✅ Sim❌ Limitado❌ Manual✅ Nativo
Fechamento imposto pelo compilador❌Não✅ Sim❌Não✅ Sim
Dados por variante✅ Sim✅ Sim⚠️Frágil✅ Sim
Legibilidade⚠️ Verboso⚠️ Posicional❌ Pobre✅ Excelente
Serialização✅ Manual⚠️ Complexo✅ Manual✅ Integrado
Padrão⚠️ Moderado✅ Baixo⚠️ Alto✅ Mínimo
Nenhuma dependência externa✅ Sim❌ NuGet✅ Sim✅ Sim

O que isso significa para o ecossistema .NET

A introdução de sindicatos discriminados terá repercussões em todo o ecossistema .NET. Aqui está o que espero ver:

O design da biblioteca será melhorado. APIs que atualmente retornam null para indicar “não encontrado” ou lançam exceções para falhas de validação poderão retornar tipos Result<T, E> em vez disso. Isso torna os modos de falha explícitos na assinatura de tipo — você pode ver o que pode dar errado observando a assinatura do método, não lendo a documentação ou o código-fonte.

A modelagem de domínio se torna mais expressiva. A lacuna entre o domínio do problema e a representação do código diminui drasticamente. Quando seu especialista em domínio diz “um pagamento pode ser um cartão de crédito, uma transferência bancária ou dinheiro na entrega”, você pode modelar isso diretamente como uma união, em vez de traduzi-lo em uma hierarquia de herança.

As ideias de F# tornam-se acessíveis aos desenvolvedores de C#. Muitos desenvolvedores de C# admiraram o sistema de tipos de F# à distância, mas não conseguiram adotá-lo em suas organizações. Os tipos de união trazem um dos recursos mais poderosos do F# para o C#, o que é uma vitória para todo o ecossistema .NET.

Menos erros de tempo de execução. A verificação exaustiva por si só evitará categorias inteiras de bugs. Cada vez que você adiciona uma nova variante a uma união, o compilador irá guiá-lo para cada lugar na base de código que precisa de atualização. Chega de casos switch esquecidos, chega de NotImplementedException em ramificações padrão que só surgem na produção.

Conclusão

Os sindicatos discriminados estão no topo da lista de desejos da comunidade C# há quase uma década, e por boas razões. Eles resolvem uma lacuna fundamental no sistema de tipos – a capacidade de modelar dados que podem ser “uma entre várias coisas” com segurança imposta pelo compilador.

A palavra-chave union proposta traz uma sintaxe limpa e concisa que se integra profundamente à correspondência de padrões do C#, funciona com genéricos, oferece suporte a métodos e interfaces e permite verificação exaustiva que detecta bugs em tempo de compilação em vez de tempo de execução.

Esteja você construindo modelos de domínio, projetando APIs com tipos de erros explícitos, implementando máquinas de estado ou apenas tentando substituir hierarquias de herança desajeitadas por algo mais natural – os tipos de união mudarão a forma como você escreve C#.

Estamos esperando há muito tempo por isso. A sintaxe é elegante, a integração com os recursos de linguagem existentes é cuidadosa e o impacto prático será sentido em todo o ecossistema .NET.

Fique de olho nas versões prévias, experimente o recurso e forneça feedback à equipe de design de linguagem. Este é um daqueles recursos que, depois de usá-lo, você se perguntará como conseguiu viver sem ele.