Union types no C# 15: substituindo o Result por tipos de domínio
Os union types no C# 15 permitem trocar o Result<T> genérico por tipos de domínio que o compilador conhece e verifica. Durante anos, a comunidade .NET buscou uma forma elegante de representar falhas de regra de negócio sem recorrer a exceptions, e a resposta mais comum foi uma classe Result<T> escrita à mão em cada projeto.
Com o C# 15, que acompanha o .NET 11, a linguagem passa a oferecer suporte nativo a unions por meio da palavra-chave union. Esse recurso muda a forma como modelamos os resultados possíveis de uma operação de domínio.
Neste artigo, vamos analisar as limitações do Result<T> tradicional e entender o que é um union do ponto de vista da teoria de tipos. Em seguida, aplicaremos o recurso no cancelamento de um pedido, exposto por uma Minimal API.
O problema do Result genérico
Quase todo projeto que evita exceptions para regras de negócio acaba com uma classe parecida com esta:
public sealed class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(T value) { IsSuccess = true; Value = value; }
private Result(string error) { IsSuccess = false; Error = error; }
public static Result<T> Ok(T value) => new(value);
public static Result<T> Fail(string error) => new(error);
}
A implementação funciona e responde à pergunta mais básica: a operação deu certo ou não. No entanto, ela deixa três lacunas importantes no design.
Se você ainda pesa as duas abordagens, o artigo sobre exceptions vs Result pattern no .NET compara as alternativas em detalhes.
O erro é apenas uma string
Quem consome o método não sabe quais falhas podem acontecer sem abrir a implementação. A assinatura Result<Order> Cancel() não comunica se o pedido pode já estar cancelado, se o prazo expirou ou se existe outra restrição.
Na prática, o tratamento acaba dependendo de comparações de texto ou de códigos mágicos. Assim, qualquer alteração na mensagem de erro pode quebrar silenciosamente um consumidor.
Estados inválidos são representáveis
Nada impede que o código acesse Value quando a operação falhou, ou Error quando ela teve sucesso. O compilador não oferece qualquer proteção contra esse tipo de engano.
O resultado é um código repleto de verificações como if (!result.IsSuccess). Essas verificações dependem da disciplina do desenvolvedor, e não de garantias da linguagem.
Novos erros passam despercebidos
Se amanhã surgir uma nova regra de negócio, com um novo motivo de falha, nenhum ponto do código recebe sinalização. Os consumidores continuam compilando, e o novo caso cai em algum tratamento genérico ou fica sem tratamento.
Esse é talvez o problema mais grave, porque o custo aparece apenas em produção.
O que são union types no C#
Os union types no C# chegam com a palavra-chave union. Um union declara que um valor é exatamente um tipo dentro de um conjunto fechado de alternativas, chamadas de case types.
Tipo soma versus tipo produto
Na teoria de tipos, um union recebe o nome de tipo soma, ou sum type. O valor é A ou B ou C, nunca mais de um ao mesmo tempo.
Por outro lado, uma classe ou record convencional é um tipo produto. Ela contém A e B e C simultaneamente, como propriedades que coexistem na mesma instância.
Linguagens como F#, Rust, Haskell e TypeScript já oferecem tipos soma há bastante tempo. No C#, a alternativa era simular esse comportamento com hierarquias de classes ou com bibliotecas de terceiros.
Declaração, conversão implícita e exaustividade
A declaração de um union é direta, porque basta listar os case types entre parênteses:
public record class Cat(string Name);
public record class Dog(string Name);
public union Pet(Cat, Dog);
Três características tornam os union types no C# relevantes para modelagem de domínio. A primeira é o conjunto fechado: um Pet só pode conter um Cat ou um Dog, e nenhum código externo acrescenta uma terceira alternativa.
A segunda é a conversão implícita. O compilador gera uma conversão de cada case type para o union, então Pet pet = new Dog("Rex"); compila sem new Pet(...).
A terceira é a exaustividade. Como o compilador conhece todos os casos, uma expressão switch que cobre cada um deles dispensa o braço _ =>.
Pet pet = new Dog("Rex");
var name = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
};
Se algum caso ficar de fora, o compilador emite o aviso de switch não exaustivo. Essa é a propriedade que resolve o terceiro problema do Result<T>.
O que o compilador gera para union types no C#
Um union não é uma construção mágica do runtime. O compilador transforma a declaração em uma struct marcada com o atributo [Union], que implementa a interface IUnion do namespace System.Runtime.CompilerServices.
De forma simplificada, a declaração public union Pet(Cat, Dog); equivale ao código abaixo:
[Union]
public struct Pet : IUnion
{
public Pet(Cat value) => Value = value;
public Pet(Dog value) => Value = value;
public object? Value { get; }
}
Existe um construtor público para cada case type e uma propriedade Value do tipo object?. Além disso, os case types não precisam compartilhar classe base ou interface, o que diferencia o union de uma hierarquia abstrata.
O pattern matching também atua diretamente sobre o conteúdo do union. Ao escrever pet is Dog, o compilador testa a propriedade Value, sem que você precise acessá-la manualmente.
Preparando o ambiente para union types no C#
Os union types no C# fazem parte do C# 15 e, no momento em que este conteúdo foi escrito, estavam disponíveis apenas no SDK de preview do .NET 11. A versão LTS vigente continua sendo o .NET 10, portanto o recurso ainda não era indicado para produção.
Para experimentar, instale o SDK de preview do .NET 11 e ajuste o arquivo de projeto:
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>
O runtime traz os tipos UnionAttribute e IUnion a partir do .NET 11 Preview 5. Por isso, versões anteriores do preview não compilam os exemplos deste artigo.
Modelando o cancelamento com union types no C#
Vamos aplicar os union types no C# em uma regra comum de e-commerce. O cliente pode cancelar um pedido em até 24 horas após a criação, e um pedido já cancelado não admite novo cancelamento.
Resultados como tipos de negócio
Em vez de strings, cada desfecho possível passa a ser um tipo próprio, que carrega apenas os dados relevantes para aquele cenário.
public sealed record OrderCanceled(Guid Id, DateTime CanceledAt);
public sealed record OrderAlreadyCanceled(Guid Id, DateTime CanceledAt);
public sealed record CancellationDeadlineExpired(Guid Id, TimeSpan Deadline);
public union CancellationResult(
OrderCanceled,
OrderAlreadyCanceled,
CancellationDeadlineExpired);
Repare que o sucesso também é um caso nomeado. Não existe um Ok genérico, e sim um OrderCanceled, que fala a linguagem do negócio.
Essa abordagem também facilita a comunicação com especialistas de domínio. Afinal, os nomes dos tipos correspondem aos termos usados nas conversas sobre a regra.
O método de domínio
Agora, a assinatura do método documenta, por si só, todos os resultados possíveis.
public sealed class Order
{
// Prazo máximo para cancelar o pedido
private static readonly TimeSpan CancellationDeadline = TimeSpan.FromHours(24);
public Order(Guid id, DateTime createdAt)
{
Id = id;
CreatedAt = createdAt;
}
public Guid Id { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? CanceledAt { get; private set; }
public CancellationResult Cancel(DateTime now)
{
if (CanceledAt is not null)
return new OrderAlreadyCanceled(Id, CanceledAt.Value);
if (now - CreatedAt > CancellationDeadline)
return new CancellationDeadlineExpired(Id, CancellationDeadline);
CanceledAt = now;
return new OrderCanceled(Id, now);
}
}
O método devolve diretamente o record de cada situação. Então, a conversão implícita gerada pelo compilador transforma o record em CancellationResult, sem fábricas como Result.Ok ou Result.Fail.
Outro ponto de design é o parâmetro now. Receber o instante atual como argumento mantém o domínio determinístico e facilita os testes, pois o método não depende de DateTime.UtcNow.
Consumindo union types no C# em uma Minimal API
Na camada de API, os union types no C# se encaixam naturalmente com a expressão switch. Dessa forma, cada caso vira uma resposta HTTP adequada.
app.MapPost("/orders/{id:guid}/cancel",
async (Guid id, AppDbContext db, TimeProvider clock) =>
{
var order = await db.Orders.FindAsync(id);
if (order is null)
return Results.NotFound();
var result = order.Cancel(clock.GetUtcNow().UtcDateTime);
// Persiste somente quando o cancelamento aconteceu
if (result is OrderCanceled)
await db.SaveChangesAsync();
return result switch
{
OrderCanceled ok => Results.Ok(ok),
OrderAlreadyCanceled e => Results.Conflict(e),
CancellationDeadlineExpired e => Results.UnprocessableEntity(e),
};
});
O TimeProvider, disponível desde o .NET 8, fornece o instante atual de forma abstraída. Em testes de integração, você pode substituí-lo pelo FakeTimeProvider do pacote Microsoft.Extensions.TimeProvider.Testing.
Não há braço _ => nem default. Isso acontece porque o compilador sabe que CancellationResult só pode conter três tipos e considera o switch exaustivo.
O teste result is OrderCanceled também merece atenção. Ele se aplica ao conteúdo do union, o que permite persistir as alterações somente quando o cancelamento realmente aconteceu.
Union types no C# como checklist do compilador
O maior benefício dos union types no C# aparece quando a regra evolui. Suponha que o negócio passe a proibir o cancelamento de pedidos já enviados.
public sealed record OrderAlreadyShipped(Guid Id, DateTime ShippedAt);
public union CancellationResult(
OrderCanceled,
OrderAlreadyCanceled,
CancellationDeadlineExpired,
OrderAlreadyShipped);
A partir desse momento, todo switch sobre CancellationResult que não trata OrderAlreadyShipped passa a gerar o aviso CS8509, de switch não exaustivo. Isso inclui endpoints, handlers de mensagens, jobs e qualquer outro consumidor.
TreatWarningsAsErrors e WarningsAsErrors
Com a opção abaixo habilitada, o aviso vira erro e o build falha:
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
Na prática, o compilador passa a funcionar como um checklist de regras de negócio. Nenhum consumidor esquece o novo caso, porque o código simplesmente não compila.
Caso a equipe não queira transformar todos os avisos em erros, a propriedade WarningsAsErrors restringe o comportamento apenas ao CS8509:
<PropertyGroup>
<WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors>
</PropertyGroup>
Com isso, os union types no C# deixam de ser apenas uma conveniência sintática. Eles passam a proteger cada evolução da regra de negócio.
Testando o domínio com union types no C#
Como o método de domínio recebe o instante atual e devolve um union, os testes ficam expressivos e sem dependências externas.
public class OrderTests
{
private static readonly DateTime CreatedAt = new(2026, 9, 1, 10, 0, 0, DateTimeKind.Utc);
[Fact]
public void Should_cancel_within_deadline()
{
var order = new Order(Guid.NewGuid(), CreatedAt);
var result = order.Cancel(CreatedAt.AddHours(2));
Assert.True(result is OrderCanceled);
}
[Fact]
public void Should_refuse_when_deadline_expired()
{
var order = new Order(Guid.NewGuid(), CreatedAt);
var result = order.Cancel(CreatedAt.AddHours(25));
Assert.True(result is CancellationDeadlineExpired);
}
}
Com union types no C#, cada teste verifica o tipo de resultado esperado, e não uma mensagem de texto. Portanto, se a redação de uma mensagem mudar, nenhum teste quebra sem motivo.
Union types no C#, closed hierarchies e Result genérico
O C# 15 traz ainda outro recurso relacionado, as closed hierarchies. Com o modificador closed, uma classe só aceita descendentes diretos no mesmo assembly, e o switch sobre ela também se torna exaustivo.
public closed record class CancellationOutcome;
public record class Canceled(Guid Id) : CancellationOutcome;
public record class AlreadyCanceled(Guid Id) : CancellationOutcome;
Segundo a documentação, uma classe closed também é implicitamente abstrata. A escolha entre os dois recursos depende do cenário, e a tabela abaixo resume os critérios mais relevantes.
| Critério | Union | Closed hierarchy | Result manual |
|---|---|---|---|
| Exige herança comum | Não | Sim | Não |
| Exaustividade no switch | Sim | Sim | Não |
| Aceita tipos de terceiros como caso | Sim | Não | Parcialmente |
| Permite membros compartilhados na base | Limitado | Sim | Sim |
| Representação | Struct com object? |
Classe de referência | Classe de referência |
Unions servem melhor quando os casos são tipos independentes, possivelmente de assemblies diferentes, e o objetivo é apenas agrupá-los. Já as closed hierarchies fazem mais sentido quando os casos compartilham comportamento ou dados na classe base.
A referência oficial de union types mostra ainda um meio termo. Você pode declarar um Result<T> genérico como union, por exemplo public union Result<T>(T, Exception);, eliminando os estados inválidos sem abandonar totalmente o padrão.
Cuidados antes de adotar union types no C#
Os union types no C# são poderosos, mas têm limitações que você precisa conhecer antes de uma migração ampla.
Ainda é preview
Na época desta publicação, o .NET 11 ainda estava em fase de preview. A própria documentação informa que parte da especificação ainda não chegou ao compilador e que a sintaxe pode mudar até a versão final.
Por isso, a recomendação é experimentar em protótipos e projetos internos, mantendo o código de produção no .NET 10 até o lançamento estável. O artigo sobre union types no .NET 11 acompanha a evolução do recurso.
Casos precisam ser tipos distintos
Um union de (string, string) não tem como diferenciar um caso do outro. Quando mais de um case type aceita o mesmo valor, a conversão fica ambígua e o compilador reporta erro.
Por isso, represente erros de domínio com records próprios. Essa restrição acaba reforçando uma boa prática de modelagem.
Value types sofrem boxing
O union gerado pelo compilador guarda o conteúdo em uma propriedade object?. Consequentemente, o runtime aloca no heap qualquer caso int, double ou struct.
Em código de domínio que usa union types no C#, esse custo raramente importa. Em caminhos críticos de performance, porém, a alternativa é implementar um union customizado com o padrão de acesso sem boxing, usando HasValue e métodos TryGetValue.
O valor default de um union
Como o union é uma struct, a expressão default(CancellationResult) produz uma instância cuja propriedade Value vale null. Nesse caso, um switch sem braço para null lança SwitchExpressionException em tempo de execução.
O compilador rastreia o estado de nulidade e emite aviso quando o valor pode ser nulo. Ainda assim, é prudente evitar campos e propriedades não inicializados do tipo union.
Sem igualdade nem desconstrução
Diferente de um record, o union não gera igualdade estrutural, clonagem ou desconstrução. Ele responde apenas à pergunta de qual caso está presente.
Entretanto, a igualdade e a desconstrução continuam disponíveis nos records que compõem o union, que é onde esses comportamentos fazem sentido.
Conclusão
O Result<T> resolveu por muito tempo a pergunta sobre o sucesso de uma operação. No entanto, ele escondia o motivo da falha em uma string e deixava estados inválidos ao alcance de qualquer consumidor.
Os union types no C# 15 colocam cada resultado possível na assinatura do método. Assim, o domínio expressa suas regras por meio de tipos, e o compilador garante que nenhum caso fique esquecido.
O ganho não está apenas em escrever menos código. Na verdade, está em transformar o compilador em um aliado da regra de negócio, capaz de apontar cada ponto que precisa mudar quando o domínio evolui.
Enquanto o .NET 11 não chega à versão estável, vale experimentar o recurso em projetos pessoais e preparar a modelagem do domínio. Quando a versão final sair, a migração será muito mais natural.
FAQ
Union types já podem ser usados em produção?
Na época desta publicação, não era recomendado. O recurso faz parte do C# 15, disponível no SDK de preview do .NET 11, e a sintaxe ainda podia mudar.
Preciso de alguma biblioteca externa para usar unions?
Não. O suporte é nativo da linguagem, e os tipos UnionAttribute e IUnion estão no runtime a partir do .NET 11 Preview 5.
Qual a diferença entre um union e uma interface de marcação?
Uma interface é aberta, e qualquer tipo pode implementá-la. O union é fechado, e o compilador usa a lista de casos para verificar a exaustividade.
Um union pode ter métodos e propriedades próprios?
Sim. A declaração aceita um corpo com membros adicionais, mas não permite campos de instância, auto properties nem construtores públicos de um único parâmetro.
O que acontece se eu adicionar um novo caso ao union?
Todo switch que não trata o novo caso passa a emitir o aviso CS8509. Com TreatWarningsAsErrors habilitado, o build falha até que você ajuste todos os consumidores.
Posso usar union com tipos primitivos?
Sim, desde que os casos sejam distintos, como em union IntOrString(int, string). Nesse caso, os value types sofrem boxing.
Unions substituem closed hierarchies?
Não. Unions agrupam tipos independentes, enquanto closed hierarchies restringem a herança de uma classe base, e os dois recursos se complementam.