Em fevereiro de 2024 eu escrevi um post sobre o quanto Discriminated Unions fazia falta no C#.
Na época, sem suporte nativo na linguagem, a gente apelava para as gambiarras clássicas: uma classe-curva-de-rio cheia de propriedades que não tinham relação entre si, tuplas de null, interfaces de marcação ou bibliotecas como OneOf.
O problema não era simplesmente "quero retornar mais de um tipo".
O problema era outro:
Eu quero que o tipo do meu programa consiga dizer quais são todas as possibilidades que um valor pode assumir e quero que o compilador me ajude a garantir que todas elas sejam tratadas.
Pois bem.
Estamos em 2026 e o C# 15 traz Union Types para a linguagem. A feature está disponível atualmente em preview junto com o .NET 11. (Microsoft Learn)
Como diria o caipira:
Tardô, mais num faiô!
Minha ideia aqui é pegar exatamente o mesmo problema que apresentei no post de 2024 e resolvê-lo usando Union Types.
Como de costume, pega aquele café e vem comigo.
Porém, antes de falar de Union Types, vamos falar do problema
Se você não leu o post anterior, deixa eu contextualizar.
Imagine uma classe responsável por administrar solicitações de aumento de limite de crédito.
Temos um método que recebe o Id do cliente e o novo limite solicitado.
Esse método pode terminar de algumas maneiras diferentes:
- O cliente não existe.
- O cliente está inativado.
- O cliente já possui uma solicitação em análise.
- O limite solicitado ultrapassa o máximo permitido.
- A solicitação foi enviada para análise.
Embora existam várias regras de negócio, temos cinco formas distintas de resultado.
E cada resultado pode carregar informações diferentes.
Por exemplo:
-
ClienteNaoEncontradoprecisa doIdCliente. -
ClienteInativadoprecisa doIdClientee doValorDevido. -
ClienteEmAnaliseprecisa da data da solicitação. -
LimiteSolicitadoExcedeLimiteMaximoprecisa saber o tipo do cliente, o valor solicitado e o limite máximo. -
LimiteSolicitadoprecisa doIdSolicitacao.
Ou seja, temos um problema clássico de modelagem:
O método retorna exatamente um resultado, mas esse resultado pode assumir diferentes formas.
É aí que nasce a solução "curva de rio"!
No post de 2024, uma das soluções possíveis era criar uma classe abrangente:
public class ResultadoDaSolicitacao
{
public TipoResultado Resultado { get; init; }
public DateTime? DataDaSolicitacao { get; init; }
public LimiteExcedido? LimiteExcedido { get; init; }
public decimal? ValorDevido { get; init; }
public Guid? IdSolicitacao { get; init; }
}
public record LimiteExcedido(
TipoCliente Tipo,
decimal ValorSolicitado,
decimal LimiteMaximo);
O método então poderia fazer algo assim:
public ResultadoDaSolicitacao SolicitarAumento(
Guid idDoCliente,
NovoLimiteSolicitado novoLimite)
{
var cliente = clienteRepository.ObterPorId(idDoCliente);
if (cliente is null)
{
return new ResultadoDaSolicitacao
{
Resultado = TipoResultado.ClienteNaoEncontrado
};
}
if (cliente.EstaInativado)
{
return new ResultadoDaSolicitacao
{
Resultado = TipoResultado.ClienteInativado,
ValorDevido = cliente.ValorDevido
};
}
if (cliente.EmAnalise)
{
return new ResultadoDaSolicitacao
{
Resultado = TipoResultado.ClienteEmAnalise,
DataDaSolicitacao = cliente.DataDaSolicitacao
};
}
// ...
return new ResultadoDaSolicitacao
{
Resultado = TipoResultado.SolicitacaoEnviada,
IdSolicitacao = Guid.NewGuid()
};
}
Funciona? Funciona.
Mas existe um problema importante.
O tipo ResultadoDaSolicitacao permite estados que não fazem sentido.
Por exemplo:
new ResultadoDaSolicitacao
{
Resultado = TipoResultado.ClienteNaoEncontrado,
ValorDevido = 1500,
IdSolicitacao = Guid.NewGuid()
};
Nada impede isso.
O compilador não sabe que ValorDevido só deveria existir quando o resultado for ClienteInativado.
Da mesma maneira, ele não sabe que IdSolicitacao só deveria existir quando o resultado for SolicitacaoEnviada.
O contrato está implícito.
E pior: quem consome esse método precisa conhecer as regras internas para interpretar corretamente o objeto.
Algo assim:
if (resultado.Resultado == TipoResultado.ClienteNaoEncontrado)
{
// ...
}
else if (resultado.Resultado == TipoResultado.ClienteInativado)
{
// resultado.ValorDevido
}
else if (resultado.Resultado == TipoResultado.ClienteEmAnalise)
{
// resultado.DataDaSolicitacao
}
O objeto não está dizendo:
"Eu sou um destes cinco casos."
Ele está dizendo:
"Eu tenho um monte de propriedades. Boa sorte."
Só tragédia.
Na vewrdade, o que eu gostaria de escrever seria algo conceitualmente parecido com:
ResultadoDaSolicitacao =
ClienteNaoEncontrado
OU ClienteInativado
OU ClienteEmAnalise
OU LimiteSolicitadoExcedeLimiteMaximo
OU LimiteSolicitado
Perceba o OU.
Não é E:
ClienteNaoEncontrado E ClienteInativado E ClienteEmAnalise
É uma possibilidade OU outra.
E mais importante:
Somente uma delas pode estar ativa por vez.
É exatamente aí que annuntio vobis gaudium magnum: habemus Union Types!
Union Types, porra!!!
Vamos explicar o que é pelo exemplo.
Primeiro vamos definir nossos tipos:
public enum TipoCliente
{
Comum,
Especial,
Premium
}
public record ClienteNaoEncontrado(Guid IdCliente);
public record ClienteInativado(
Guid IdCliente,
decimal ValorDevido);
public record ClienteEmAnalise(
Guid IdCliente,
DateTime DataDaSolicitacao);
public record LimiteSolicitadoExcedeLimiteMaximo(
TipoCliente Tipo,
decimal ValorSolicitado,
decimal LimiteMaximo);
public record LimiteSolicitado(Guid IdSolicitacao);
Agora vem a parte interessante:
public union ResultadoDaSolicitacao(
ClienteNaoEncontrado,
ClienteInativado,
ClienteEmAnalise,
LimiteSolicitadoExcedeLimiteMaximo,
LimiteSolicitado);
Pronto.
A leitura dessa declaração é praticamente a própria documentação do domínio:
ResultadoDaSolicitacaopode serClienteNaoEncontrado,ClienteInativado,ClienteEmAnalise,LimiteSolicitadoExcedeLimiteMaximoouLimiteSolicitado.
E somente um desses casos está ativo por vez.
Union Types são justamente uma forma de representar um valor que pode assumir uma das possibilidades de um conjunto fechado de tipos. (Microsoft Learn)
Repare que não precisamos mais do Status
Essa talvez seja uma das mudanças mais interessantes.
Antes tínhamos:
Resultado = TipoResultado.ClienteInativado
ValorDevido = cliente.ValorDevido
Ou seja, tínhamos dois pedaços de informação que precisavam ser interpretados juntos.
Agora temos:
new ClienteInativado(
idDoCliente,
cliente.ValorDevido);
O próprio tipo carrega a informação de que estamos tratando de um cliente inativado.
Não precisamos de Status = ClienteInativado porque o tipo já é o status.
O método fica muito mais expressivo! Sensacional!
Agora podemos escrever:
public class ProcessadorDeLimiteDeCredito
{
private readonly IClienteRepository _clienteRepository;
public ProcessadorDeLimiteDeCredito(
IClienteRepository clienteRepository)
{
_clienteRepository = clienteRepository;
}
public ResultadoDaSolicitacao SolicitarAumento(
Guid idDoCliente,
NovoLimiteSolicitado novoLimite)
{
var cliente = _clienteRepository.ObterPorId(idDoCliente);
if (cliente is null)
return new ClienteNaoEncontrado(idDoCliente);
if (cliente.EstaInativado)
return new ClienteInativado(
idDoCliente,
cliente.ValorDevido);
if (cliente.EmAnalise)
return new ClienteEmAnalise(
idDoCliente,
cliente.DataDaSolicitacao.GetValueOrDefault());
if (cliente.Tipo == TipoCliente.Comum &&
novoLimite.Valor > 1000)
{
return new LimiteSolicitadoExcedeLimiteMaximo(
cliente.Tipo,
novoLimite.Valor,
1000);
}
if (cliente.Tipo == TipoCliente.Especial &&
novoLimite.Valor > 1500)
{
return new LimiteSolicitadoExcedeLimiteMaximo(
cliente.Tipo,
novoLimite.Valor,
1500);
}
if (cliente.Tipo == TipoCliente.Premium &&
novoLimite.Valor > 2000)
{
return new LimiteSolicitadoExcedeLimiteMaximo(
cliente.Tipo,
novoLimite.Valor,
2000);
}
var idDaSolicitacao =
EnviarParaAnalise(cliente, novoLimite);
return new LimiteSolicitado(idDaSolicitacao);
}
private Guid EnviarParaAnalise(
Cliente cliente,
NovoLimiteSolicitado novoLimite)
=> Guid.NewGuid();
}
Cada return devolve um tipo diferente.
Mas todos eles podem ser implicitamente convertidos para ResultadoDaSolicitacao, porque são cases declarados nesse union. (GitHub)
E aqui temos uma diferença importante em relação ao object.
Com object resultado; qualquer coisa poderia ser colocada como valor.
E com ResultadoDaSolicitacao resultado; o compilador sabe exatamente quais tipos são permitidos.
O contrato deixou de ser uma convenção e agora ele faz parte do sistema de tipos.
E como eu uso? É agora que a brincadeira fica bacanuda.
Antes tínhamos uma sequência de ifs:
if (resultado.Resultado == TipoResultado.ClienteNaoEncontrado)
{
// ...
}
if (resultado.Resultado == TipoResultado.ClienteInativado)
{
// ...
}
Agora podemos fazer:
return resultado switch
{
ClienteNaoEncontrado cne =>
Results.NotFound(cne),
ClienteInativado ci =>
Results.BadRequest(ci),
ClienteEmAnalise cea =>
Results.Accepted(cea),
LimiteSolicitadoExcedeLimiteMaximo lselm =>
Results.BadRequest(lselm),
LimiteSolicitado ls =>
Results.Created(
$"/clientes/{idCliente}/limite-de-credito/solicitacoes/{ls.IdSolicitacao}")
};
Simples, simples, simples.
Mas... e seu eu criar uma nova possibilidade?
É aqui que entra a tal Exaustividade.
Se eu tivesse que escolher uma única razão para usar Union Types, provavelmente seria essa:
Exaustividade.
O compilador conhece todos os cases do union e consegue verificar se o switch tratou todos eles. (GitHub)
Vamos imaginar que amanhã apareça uma nova regra de negócio:
public record ClienteBloqueadoPorFraude(Guid IdCliente);
E eu altere o union:
public union ResultadoDaSolicitacao(
ClienteNaoEncontrado,
ClienteInativado,
ClienteEmAnalise,
LimiteSolicitadoExcedeLimiteMaximo,
LimiteSolicitado,
ClienteBloqueadoPorFraude);
O que acontece com aquele switch?
O compilador sabe que existe uma possibilidade nova que não foi tratada.
Você recebe um warning de não exaustividade:
The switch expression does not handle all possible values of its input type.
E se você, assim como eu, gosta de tratar warnings como errors (<TreatWarningsAsErrors>true</TreatWarningsAsErrors>) isso vira um problema de compilação.
E isso é fantástico.
E por que isso é tão importante?
Voltemos ao problema do post de 2024.
Eu tinha escrito algo parecido com:
"Podemos adicionar um novo retorno e não tratá-lo, já que não existe nada que me obrigue a fazer isso."
Esse problema simplesmente desaparece.
Se eu adiciono ClienteBloqueadoPorFraude ao union, o compilador passa a me mostrar os pontos onde esse novo caso precisa ser considerado.
Isso transforma uma responsabilidade que antes dependia de disciplina humana em uma responsabilidade compartilhada com o compilador.
E essa, para mim, é a grande diferença.
Indo um pouco mais a fundo, precisamos entender que Union Types são tipos fechados (closed).
Quando declaramos:
public union ResultadoDaSolicitacao(
ClienteNaoEncontrado,
ClienteInativado,
ClienteEmAnalise,
LimiteSolicitadoExcedeLimiteMaximo,
LimiteSolicitado);
estamos declarando um conjunto fechado de cases.
Não existe
class OutroResultado : ResultadoDaSolicitacao
para simplesmente adicionar uma nova possibilidade.
O compilador conhece o conjunto.
E é justamente esse conhecimento que permite a exaustividade.
Isso é diferente de uma interface:
public interface IResultado {}
Eu posso ter:
class ResultadoA : IResultado { }
class ResultadoB : IResultado { }
Mas nada impede que outro assembly crie:
class ResultadoC : IResultado { }
Uma interface normalmente representa um conjunto aberto de implementações.
Um union representa um conjunto fechado de cases.
Essa diferença é fundamental.
Agora Union Type tem algumas características que o diferenciam do Discriminated Union.
É muito comum chamar esse recurso de Discriminated Union, principalmente porque linguagens como F# possuem esse conceito há bastante tempo.
Mas a proposta do C# faz uma distinção importante.
Os unions do C# são type unions.
Eles não precisam necessariamente de uma tag ou de um campo discriminador explícito. A própria especificação deixa claro que os unions propostos para C# não são necessariamente "discriminated" ou "tagged" unions. (GitHub)
Por exemplo:
public union Pet(
Cat,
Dog,
Bird);
não precisa ter:
enum PetType
{
Cat,
Dog,
Bird
}
O próprio tipo armazenado representa o case.
Se o valor interno é um Dog o case é Dog.
Se é Cat o case é Cat.
É justamente por isso que o pattern matching consegue trabalhar diretamente com os tipos.
Um moundo novo se abre à partir dessa funcionalidade.
Por exemplo, podemos ter coisas como o Option<T>.
Não conhece?
Pega outro café e vem comigo.
Agora precisamos sair do exemplo de crédito e olhar para um conceito muito comum em programação funcional para podermos explicar a ideia do Option<T>.
Provavelmente você já encontrou algo como Option em F# e Rust, Maybe em Haskell, Optional em Java ou equivalentes em outras linguagens.
A ideia é extremamente simples:
Um valor pode existir ou não existir.
Podemos representar isso com:
public record None;
public record Some<T>(T Value);
public union Option<T>(
None,
Some<T>);
E para usar:
Option<int> valor = new Some<int>(42);
Option<int> vazio = new None();
var texto = valor switch
{
Some<int> some => $"O valor é {some.Value}",
None => "Não tem valor"
};
Aqui temos novamente dois cases:
Option<T>
│
├── Some<T>
└── None
É um exemplo pequeno, mas conceitualmente poderoso.
Uai, mas e o
Nullable<T>?
Existe uma diferença interessante entre int? e Option<int>
O Nullable<T> diz essencialmente:
Este valor de tipo
Tpode não possuir um valor.
Já Option<T> modela explicitamente dois cases:
Some<T> OU None
A ausência deixa de ser apenas um estado implícito e passa a fazer parte do domínio.
Isso também permite carregar informações diferentes em cada case.
Por exemplo:
public record Some<T>(T Value);
public record None(string Reason);
public union Option<T>(
Some<T>,
None);
Agora o caso de ausência também possui informação.
E é aí que Union Types começam a ficar realmente interessantes para modelagem de domínio.
Consegue enxergar os ganhos disso?
Tá, mas como isso funciona por baixo do capô?
Agora vem a parte que eu mais gosto.
Porque você pode olhar para:
public union Option<T>(
None,
Some<T>);
e pensar:
"Tá... mas o CLR ganhou algum tipo mágico novo?"
Não exatamente.
O compilador faz uma boa parte do trabalho.
A implementação da declaração union é propositalmente simples: a forma gerada pelo compilador é um struct que armazena o conteúdo em um único object/object?, com construtores para os cases. A especificação também prevê um padrão de acesso sem boxing para implementações que precisem otimizar esse cenário. (GitHub)
Em termos conceituais, nosso:
public union Option<T>(
None,
Some<T>);
se aproxima de algo como:
[Union]
public struct Option<T> : IUnion
{
private readonly object? _value;
public object? Value => _value;
public Option(None value)
=> _value = value;
public Option(Some<T> value)
=> _value = value;
}
Simplificando bastante, é isso.
Um struct, um Value e um construtor para cada case.
Não existe um enum escondido!
Não existe algo como private OptionType _type; guardado dentro do union.
O valor armazenado possui o tipo concreto do case.
Por exemplo:
Option<int> option =
new Some<int>(42);
O Value contém uma instância de Some<int> enquanto Option<int> option = new None(); contém None.
Ou seja, o tipo do objeto armazenado participa da identificação do case.
A especificação define exatamente esse modelo de union como uma composição de tipos, e não como um enum/tag obrigatório. (GitHub)
E se você entendeu minimamente a estrutura, pode estar se perguntando:
Mas por que
object?
Porque o union precisa armazenar diferentes tipos. Parece óbvio, mas é importante entender que o object é a forma mais simples de armazenar tipos diferentes em um único campo.
Imagine:
public union Something(
int,
string,
Cliente);
O campo interno precisa conseguir armazenar int OU string OU Cliente.
A forma padrão da declaração union usa um único object? como armazenamento.
Isso tem um efento colateral: tipos de valor precisam ser boxed.
Se um dos cases for um tipo de valor:
public union Something(
int,
string);
o int precisa ser boxed quando armazenado no object.
A documentação oficial deixa isso explícito: a implementação padrão das declarações union armazena o conteúdo em uma única referência object, portanto tipos de valor são boxed. (Microsoft for Developers)
Isso não significa que Union Types sejam automaticamente "lentos".
Significa que precisamos entender qual representação estamos utilizando.
Para a grande maioria dos casos, a implementação padrão pode ser perfeitamente adequada.
Mas em cenários de alta performance, especialmente quando os cases são tipos de valor, isso é algo que merece atenção.
Em resumo, não se preocupe com isso. Se o seu problema for performance, provavelmente existe outras coisas mais prioritárias para resolver antes de se preocupar com boxing do union types.
Aliás, existe uma alternativa sem boxing.
A especificação prevê um padrão de acesso chamado non-boxing union access pattern.
Uma implementação customizada pode fornecer mecanismos comoHasValue e TryGetValue para que o compilador consiga acessar os cases sem depender exclusivamente de object e boxing. (GitHub).
Isso é particularmente interessante para bibliotecas que já possuem uma implementação própria de union e querem aproveitar os recursos da linguagem sem necessariamente adotar a representação padrão.
Ou seja:
Union Types não obrigam todo mundo a usar exatamente a mesma representação interna.
A declaração union é uma forma conveniente e opinativa de declarar um union.
Tipos existentes podem implementar o padrão de union e ganhar os comportamentos da linguagem.
Isso é sensacional, sem duvida.
Bom, e o switch?
Agora já dá para entender melhor o que acontece.
Quando escrevemos:
var texto = option switch
{
Some<int> some => $"O valor é {some.Value}",
None => "Não tem valor"
};
o compilador precisa descobrir qual case está dentro do union.
Conceitualmente, isso acaba se aproximando de verificações de tipo:
if (value is Some<int> some)
{
// ...
}
else if (value is None)
{
// ...
}
else
{
throw new SwitchExpressionException();
}
O código real gerado é mais complexo e pode variar conforme a implementação e as otimizações, então não devemos tratar esse pseudo-código como uma representação literal da IL.
O importante é entender a ideia:
O
unionfornece ao compilador conhecimento sobre os cases; o pattern matching usa esse conhecimento para gerar o código de seleção e para verificar a exaustividade.
A própria implementação padrão usa um único campo de armazenamento e construtores específicos para cada case. (GitHub)
E o que acontece se o switch não for exaustivo? Essa é uma das partes mais legais.
Se o union possui:
public union Pet(
Cat,
Dog,
Bird);
e eu faço:
var name = pet switch
{
Cat cat => cat.Name,
Dog dog => dog.Name
};
O compilador sabe que existe um case não tratado (Bird) e emite um warning de não exaustividade. (Por isso eu sempre trato meus warnings como errors.)
Isso não é possível com a mesma segurança quando estamos simplesmente trabalhando com object ou uma interface.
E esse é o ponto central de tudo. É isso que você precisa ter em mente sempre que estiver pensando em Union Types.
Aliás, Union Types não são apenas para retorno de métodos!
Esse é outro ponto importante. É fácil olhar para o exemplo e pensar:
"Ah, então Union Types são uma forma melhor de retornar vários resultados."
Não. Esse é apenas um dos usos.
Durante muitos anos, o C# foi incorporando conceitos que linguagens funcionais já utilizavam há bastante tempo.
Tivemos Pattern Matching, Records, Nullable Reference Types, List/Collection Expressions e agora Union Types fecham uma lacuna particularmente importante que é representar conjuntos fechados de tipos.
Eu penso que a partir de agora, muitas sistemas que antes eram modelados com classes genéricas, interfaces ou enums, podem ser repensados para usar Union Types, tornando o código mais expressivo e seguro.
Além disso, muitas bibliotecas serão criadas para "emular" algumas funcionalidades de linguagens funcionais, como Option<T>, Result<TSuccess, TFailure>, Either<L, R> e outras abstrações que se beneficiam de conjuntos fechados de tipos.
Ótima evolução do C#. Só quero ver o que vem por aí.
Era isso. Muito obrigado.
Até a próxima!









