Union types no C# 15: como criar workflows serializáveis

Union types no C# 15: como criar workflows serializáveis

Os union types no C# 15 permitem declarar um tipo que guarda exatamente um valor dentro de uma lista fechada de tipos, e o compilador verifica se todos os casos foram tratados. Neste artigo, você vai usar esse recurso para construir um workflow de aprovação de despesas, com um único método de transição e estado persistido em JSON.

Todo o código foi escrito para o .NET 11, que está em fase de release candidate. Por isso, alguns detalhes ainda podem mudar até a versão final.

O que são union types no C# 15

Por muitos anos, quem precisava devolver um entre vários tipos no C# recorria a object, a interfaces marcadoras, a classes base ou a bibliotecas como OneOf. Nenhuma dessas opções impedia que alguém adicionasse um novo tipo sem que o compilador avisasse os pontos de consumo.

Com os union types no C# 15, você declara a lista de tipos permitidos com a palavra-chave union. A partir daí, o conjunto fica fechado e nenhum outro tipo pode entrar nele.

public union LookupResult(Found, NotFound);

public record Found(string Name);
public record NotFound(string Key);

Nesse exemplo, Found e NotFound recebem o nome de case types. Eles são tipos comuns, declarados fora da union, e records combinam muito bem com esse papel.

Além disso, o compilador gera conversões implícitas de cada case type para a union. Por isso, você escreve LookupResult result = new Found("Ana"); sem chamar nenhum construtor especial.

static string Describe(LookupResult result) => result switch
{
    Found(var name) => $"Found {name}",
    NotFound(var key) => $"Nothing found for {key}",
};

Repare que a switch expression não tem um braço de descarte. Como ela cobre todos os casos, o compilador a considera exaustiva e não emite nenhum aviso.

Em outras palavras, quando um novo caso entrar na union, cada switch que esqueceu de tratá-lo vai acender um aviso. Essa é a propriedade que torna os union types no C# 15 tão úteis para máquinas de estado e workflows.

Como o compilador representa union types no C# 15

Na prática, cada declaração union vira uma struct gerada pelo compilador. Essa struct guarda o caso ativo em uma única propriedade Value do tipo object?.

O pattern matching, por sua vez, é transparente. Quando você escreve result is Found, o teste acontece sobre o conteúdo de Value, e não sobre a struct em si.

Vale destacar também que essas unions são uniões de tipos, e não enums com dados anexados. Segundo a especificação, uma union discriminada surge quando você cria um tipo novo para cada caso, que é exatamente o que faremos aqui.

Os detalhes de conversões, nulabilidade e exaustividade estão na referência oficial sobre tipos union do Microsoft Learn.

Preparando o projeto no .NET 11

Para acompanhar os exemplos, instale o SDK do .NET 11 e crie um projeto de console com dotnet new console. Em seguida, ajuste o arquivo do projeto para apontar para o framework net11.0.

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net11.0</TargetFramework>
  <LangVersion>15.0</LangVersion>
  <Nullable>enable</Nullable>
</PropertyGroup>

A partir do RC1, o C# 15 já é a versão padrão para projetos net11.0. Mesmo assim, fixar o LangVersion deixa explícito para o time de qual versão da linguagem o projeto depende.

Se você usa o Homebrew no macOS, confira a versão com dotnet --version antes de compilar. Às vezes o dotnet do Homebrew aparece primeiro no PATH e aponta para um SDK mais antigo.

Caso ainda não tenha acompanhado outras novidades dessa versão, vale ler também como evitar alocações com o StringBuilder.MoveChunks no .NET 11. Embora sejam recursos diferentes, os dois mostram a direção da plataforma para esse lançamento.

Modelando um workflow com union types no C# 15

Neste artigo, um workflow é apenas um estado somado a um método que decide o próximo passo. Assim, todo o modelo cabe em quatro unions pequenas, cada uma respondendo a uma pergunta diferente.

Union Pergunta que responde Casos
ExpenseState Em que etapa a despesa está? Created, Submitted, UnderReview, Approved, Rejected, Paid
StepInput O que o chamador está enviando? NoInput, Decision
InputRequest O que uma etapa em espera precisa? ReviewRequest, FinanceReviewRequest
StepResult O que o chamador deve fazer agora? Advance, AwaitInput, Finish, Fail

O exemplo modela o reembolso de despesas de uma empresa. Despesas pequenas passam por uma aprovação simples, enquanto valores acima de um limite exigem também a revisão do financeiro.

// Onde a despesa está. Cada estado carrega apenas os dados de que precisa.
public union ExpenseState(Created, Submitted, UnderReview, Approved, Rejected, Paid);

public record Created(string ExpenseId, decimal Amount);
public record Submitted(string ExpenseId, decimal Amount);
public record UnderReview(string ExpenseId, decimal Amount);
public record Approved(string ExpenseId, decimal Amount);
public record Rejected(string ExpenseId, string Reason);
public record Paid(string ExpenseId);

// O que o chamador envia para o workflow.
public union StepInput(NoInput, Decision);

public record NoInput;
public record Decision(bool IsApproved, string? Note);

// O que uma etapa em espera pede ao chamador.
public union InputRequest(ReviewRequest, FinanceReviewRequest);

public record ReviewRequest(string Question);
public record FinanceReviewRequest(string Question, decimal Amount);

// O que o método Next orienta o chamador a fazer.
public union StepResult(Advance, AwaitInput, Finish, Fail);

public record Advance(ExpenseState State);
public record AwaitInput(InputRequest Request);
public record Finish(ExpenseState State);
public record Fail(string Reason);

O StepResult funciona como o contrato do workflow. Toda chamada ao Next devolve um entre quatro resultados, e o chamador precisa tratar todos eles.

  • Advance: o workflow avançou, então o chamador pode chamar Next de novo.
  • AwaitInput: a etapa precisa de uma resposta, então o chamador deve coletá-la.
  • Finish: o workflow chegou a um estado terminal, então o chamador deve parar.
  • Fail: a entrada não pertence à etapa atual, então o chamador deve tratar o erro.

Com essa divisão, os union types no C# 15 deixam cada responsabilidade em um tipo próprio. O estado não sabe nada sobre o chamador, e o chamador não precisa conhecer as regras internas de cada etapa.

Uma convenção de nomes que evita colisões

Como os casos não ficam aninhados dentro da union, cada nome divide o mesmo namespace com o resto do código. Portanto, uma convenção clara evita conflitos e deixa a leitura mais fluida.

Primeiro, os estados ficam no particípio, porque descrevem algo que já aconteceu, como Submitted, Approved e Paid. Em seguida, os resultados usam verbos no imperativo, como Advance e Fail, porque são instruções ao chamador.

Por fim, entradas e pedidos são substantivos, como Decision e FinanceReviewRequest. Dessa forma, estado é condição, resultado é comando, e os dois conjuntos nunca se misturam.

Observe também a diferença entre Fail e Rejected. O Fail representa uma falha técnica, como uma entrada enviada para a etapa errada, enquanto uma recusa de negócio chega como um Finish carregando o estado Rejected.

Tipando a entrada do workflow

A tentação natural seria declarar Next(object? input) e verificar o tipo dentro de cada etapa. Esse código compila, mas o compilador deixa de saber o que cada etapa espera receber.

Além disso, o null acaba acumulando dois significados. Ele pode indicar que não existe entrada ou que o chamador cometeu um erro, e o código não consegue distinguir as duas situações.

Com o StepInput, o null simplesmente desaparece do contrato. O caso NoInput significa apenas "qual é o próximo passo?", e cada etapa precisa tratar todos os tipos de entrada.

Da mesma forma, uma etapa que aguarda resposta descreve o que precisa por meio de um InputRequest tipado. Desse modo, o chamador recebe dados estruturados no lugar de uma simples string de prompt.

Essa ideia conversa diretamente com a modelagem orientada a tarefas. Se quiser aprofundar, o artigo sobre CRUD e task based no .NET 10 mostra como capturar a intenção do usuário em APIs.

Na prática, tipar a entrada com union types no C# 15 transforma um contrato implícito em um contrato verificável. Quem chama o workflow passa a saber exatamente o que pode enviar, e o compilador confere isso em cada etapa.

Implementando o método Next com union types no C# 15

Com os tipos definidos, o workflow inteiro cabe em uma classe pequena. O método Next é uma única switch expression, então o compilador confere cada estado e cada entrada das etapas que aguardam resposta.

public sealed class ExpenseWorkflow
{
    private const decimal FinanceReviewThreshold = 1000m;

    public ExpenseState State { get; private set; }

    private ExpenseWorkflow(ExpenseState state) => State = state;

    public static ExpenseWorkflow Create(string expenseId, decimal amount) =>
        new(new Created(expenseId, amount));

    public static ExpenseWorkflow FromState(ExpenseState state) => new(state);

    // O único método do workflow: recebe uma entrada tipada e devolve um StepResult
    public StepResult Next(StepInput input) => State switch
    {
        Created(var id, var amount) =>
            NoInputOnly(input, () => MoveTo(new Submitted(id, amount))),

        Submitted(var id, var amount) => input switch
        {
            NoInput => new AwaitInput(new ReviewRequest("Approve this expense?")),
            Decision(false, _) => EndWith(new Rejected(id, "Rejected by the manager.")),
            Decision(true, _) when amount > FinanceReviewThreshold => MoveTo(new UnderReview(id, amount)),
            Decision(true, _) => MoveTo(new Approved(id, amount)),
        },

        UnderReview(var id, var amount) => input switch
        {
            NoInput => new AwaitInput(new FinanceReviewRequest("Finance review required.", amount)),
            Decision(false, _) => EndWith(new Rejected(id, "Rejected by finance.")),
            Decision(true, _) => MoveTo(new Approved(id, amount)),
        },

        Approved(var id, _) => NoInputOnly(input, () => EndWith(new Paid(id))),

        Rejected => EndWith(State),
        Paid => EndWith(State),
    };

    private StepResult MoveTo(ExpenseState next)
    {
        State = next;
        return new Advance(State);
    }

    private StepResult EndWith(ExpenseState terminal)
    {
        State = terminal;
        return new Finish(State);
    }

    // Etapas que não aguardam resposta recusam qualquer entrada inesperada
    private static StepResult NoInputOnly(StepInput input, Func<StepResult> step) => input switch
    {
        NoInput => step(),
        Decision => new Fail("This step does not accept input."),
    };
}

Repare que o método nunca lança exceção para entradas fora de lugar. Em vez disso, o helper NoInputOnly devolve um Fail, e o chamador decide o que fazer com ele.

Além disso, cada estado carrega apenas os dados que fazem sentido para ele. Um Rejected tem motivo, um Paid tem só o identificador, e nenhum campo fica vazio esperando um valor.

O ganho aparece de verdade na manutenção. Se você adicionar um novo caso ao StepInput, cada input switch do código passa a emitir aviso até você tratar o novo caso.

O mesmo vale para os estados, já que o switch externo também é exaustivo. Com union types no C# 15, o compilador se torna um revisor que nunca esquece um caminho do fluxo.

Executando o workflow

O chamador fica em um laço até receber um Finish ou um Fail. Quando o workflow pede uma resposta, o chamador também usa um switch sobre o tipo do pedido, que é igualmente exaustivo.

static StepResult Run(ExpenseWorkflow workflow, Queue<bool> answers)
{
    StepInput input = new NoInput();

    while (true)
    {
        var result = workflow.Next(input);

        switch (result)
        {
            case Finish:
            case Fail:
                return result;
            case AwaitInput(var request):
                // Em produção, a resposta viria de uma pessoa ou de um webhook
                input = Answer(request, answers.Dequeue());
                break;
            case Advance:
                input = new NoInput();
                break;
        }
    }
}

static StepInput Answer(InputRequest request, bool approve) => request switch
{
    ReviewRequest => new Decision(approve, null),
    FinanceReviewRequest => new Decision(approve, "Reviewed by finance"),
};

Para uma despesa de 250, o fluxo percorre Created, Submitted, Approved e Paid. Já uma despesa de 9.999 passa por UnderReview, e uma recusa do financeiro termina no estado Rejected.

Por outro lado, enviar uma decisão para uma etapa que não espera entrada devolve um Fail, sem exceção nenhuma. Esse comportamento previsível facilita testes automatizados e deixa os logs mais claros.

Se você já escreveu uma máquina de estados com enum e um switch gigante, a diferença fica evidente. Aqui, cada estado é um tipo próprio com seus dados.

Por isso, o laço do chamador continua curto mesmo quando o fluxo cresce. Os union types no C# 15 empurram a complexidade para os tipos, enquanto o código de orquestração permanece simples e legível.

Serializando union types no C# 15 com System.Text.Json

Um workflow que espera por uma pessoa só é útil se puder parar e continuar depois. Portanto, você precisa serializar o estado atual e reidratá-lo em outro momento, talvez em outro processo.

A partir do .NET 11, o JsonSerializer reconhece unions nativamente. Por padrão, ele grava apenas o JSON do caso ativo, sem nenhum discriminador.

{"ExpenseId":"exp-1","Amount":250}

Na leitura, o serializador classifica o JSON pelo tipo de token, ou seja, número, string ou objeto. Isso funciona bem quando cada caso tem um token diferente, mas quebra quando dois casos são objetos.

No nosso modelo, todos os estados são objetos, então Deserialize<ExpenseState> lança uma JsonException por ambiguidade. O próprio erro sugere configurar um classificador de tipos personalizado.

A documentação de serialização de unions descreve o JsonUnionTypeStructuralClassifier, que escolhe o caso pelos nomes das propriedades. No entanto, Submitted, UnderReview e Approved têm exatamente as mesmas propriedades, então nem ele resolveria a ambiguidade.

Além disso, depender do formato das propriedades para decidir o estado é arriscado em dados persistidos. Por esse motivo, a solução mais segura para union types no C# 15 é gravar um discriminador explícito que sobreviva a refatorações.

Criando um conversor com discriminador explícito

O conversor abaixo envolve cada caso em um objeto com duas propriedades: $case, com o nome do tipo, e value, com os dados. Na escrita, ele usa a propriedade Value da union para descobrir o caso ativo sem precisar de um braço por tipo.

using System.Text.Json;
using System.Text.Json.Serialization;

public sealed class ExpenseStateConverter : JsonConverter<ExpenseState>
{
    public override ExpenseState Read(
        ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        using var document = JsonDocument.ParseValue(ref reader);
        var root = document.RootElement;
        var caseName = root.GetProperty("$case").GetString();
        var value = root.GetProperty("value");

        // A conversão implícita da union transforma cada record em ExpenseState
        return caseName switch
        {
            nameof(Created) => ReadCase<Created>(value, options),
            nameof(Submitted) => ReadCase<Submitted>(value, options),
            nameof(UnderReview) => ReadCase<UnderReview>(value, options),
            nameof(Approved) => ReadCase<Approved>(value, options),
            nameof(Rejected) => ReadCase<Rejected>(value, options),
            nameof(Paid) => ReadCase<Paid>(value, options),
            _ => throw new JsonException($"Unknown ExpenseState case '{caseName}'."),
        };
    }

    public override void Write(
        Utf8JsonWriter writer, ExpenseState value, JsonSerializerOptions options)
    {
        // Value guarda o caso ativo; uma union default não tem caso nenhum
        var current = value.Value
            ?? throw new JsonException("ExpenseState has no active case.");

        writer.WriteStartObject();
        writer.WriteString("$case", current.GetType().Name);
        writer.WritePropertyName("value");
        JsonSerializer.Serialize(writer, current, current.GetType(), options);
        writer.WriteEndObject();
    }

    private static T ReadCase<T>(JsonElement element, JsonSerializerOptions options) =>
        element.Deserialize<T>(options)
            ?? throw new JsonException($"Invalid payload for {typeof(T).Name}.");
}

Na leitura, a switch expression converte cada record para ExpenseState por meio da conversão implícita da union. O braço de descarte trata nomes desconhecidos, que podem surgir em dados antigos ou corrompidos.

Na escrita, o nome do tipo vem de GetType().Name, então a tag sempre corresponde ao record real. Contudo, isso também amarra o JSON salvo ao nome da classe, um ponto que volta a aparecer quando falarmos de versionamento.

O JSON persistido fica pequeno e legível:

{"$case":"UnderReview","value":{"ExpenseId":"exp-2","Amount":9999}}

Reidratando o estado salvo

Como o workflow inteiro é o seu State, reidratar exige apenas uma linha em cada direção. Primeiro você serializa o estado, depois reconstrói a instância a partir do JSON salvo.

var options = new JsonSerializerOptions
{
    Converters = { new ExpenseStateConverter() },
};

var json = JsonSerializer.Serialize(workflow.State, options);

// O processo pode parar aqui e voltar horas depois
var restored = ExpenseWorkflow.FromState(
    JsonSerializer.Deserialize<ExpenseState>(json, options));

Você também não precisa persistir a pergunta pendente. Depois de reidratar um workflow em espera, basta chamar Next(new NoInput()) para ele refazer o mesmo pedido.

Para validar a abordagem, uma boa prática é serializar após cada chamada ao Next e continuar o fluxo com a cópia reidratada. O resultado final precisa ser idêntico ao de uma execução sem interrupções, tanto no caminho aprovado quanto nos caminhos de revisão e recusa.

Afinal, se a cópia reidratada sempre chega ao mesmo resultado, você pode guardar o estado em um banco, em uma fila ou em um blob.

Cuidados ao usar union types no C# 15

Apesar das vantagens, os union types no C# 15 têm algumas arestas que vale conhecer antes de levar o padrão para produção.

  • Prefira switch expressions. Somente a switch expression recebe a verificação de exaustividade da union. Um switch statement que encerra um método exige um throw no final, caso contrário o compilador emite o erro CS0161.
  • Cuidado com o default. Como a union é uma struct, default(ExpenseState).Value é null. Com nullable reference types ativo, o compilador emite o aviso CS8655, e um switch sobre um valor default lança SwitchExpressionException em tempo de execução.
  • Casos de tipo valor sofrem boxing. Tudo fica armazenado em um object?, então um caso int ou decimal gera alocação.
  • Operador ?. e retornos anuláveis não compilam. Você verá o erro CS0023 ao usar ?. sobre uma union e o CS0508 em um override de JsonConverter que retorne ExpenseState?.
  • Ainda é um release candidate. Segundo as notas do RC1, as unions estão estabilizadas, mas o comportamento pode mudar até a versão final.

Na prática, a regra mais importante é sempre inicializar campos e propriedades do tipo union. O construtor privado do ExpenseWorkflow cumpre esse papel, porque nenhuma instância nasce sem estado.

Quanto ao boxing, ele raramente pesa em um workflow de negócio, já que os casos aqui são records. Mesmo assim, se você aplicar union types no C# 15 em código sensível a desempenho, a documentação mostra como criar uma union personalizada que evita essa alocação.

Alternativas aos union types no C# 15 e próximos passos

O .NET 11 também traz as closed hierarchies, que limitam a herança de uma classe base ao mesmo assembly. Combinadas com o atributo JsonPolymorphic, elas gravam um discriminador $type automaticamente.

[JsonPolymorphic(InferClosedTypePolymorphism = true)]
public closed record ExpenseEvent;

public sealed record ExpenseSubmitted(string ExpenseId) : ExpenseEvent;
public sealed record ExpensePaid(string ExpenseId) : ExpenseEvent;

Segundo a documentação, essa abordagem deixa a seleção do caso mais estável quando as propriedades evoluem. Por outro lado, os tipos precisam compartilhar uma classe base, o que não acontece com os casos de uma union.

Em outras palavras, use unions quando os casos forem tipos independentes ou quando o formato do JSON não estiver sob seu controle. Já quando você controla os tipos e o contrato, a hierarquia fechada pode eliminar o conversor manual.

Alguns pontos também ficam em aberto para evoluir a solução:

  • Versionamento: renomear um estado quebra os valores de $case já salvos, então um sistema real precisa de um campo de versão ou de uma migração.
  • Native AOT e source generation: vale testar o conversor nesses cenários, já que o gerador de código reporta diagnóstico para unions ambíguas sem classificador.
  • Entradas por estado: como o StepInput é compartilhado, uma etapa ainda pode receber uma entrada destinada a outra, e evitar isso exige genéricos ou mais de um método.

Cada um desses pontos rende um experimento próprio. Ainda assim, a base construída aqui com union types no C# 15 continua valendo, porque o modelo de estados não muda com a estratégia de serialização.

Conclusão

Quatro unions pequenas e um único método Next descrevem um workflow completo, e o compilador aponta cada estado, entrada ou resultado que ficou sem tratamento. Esse é o principal ganho dos union types no C# 15: transformar regras de fluxo em verificações de compilação.

Além disso, o estado persistido vira um objeto JSON com discriminador explícito, que reidrata para o mesmo resultado de uma execução contínua. Como próximo passo, experimente trocar o conversor manual por uma hierarquia fechada e compare quanto código desaparece.

FAQ

Union types no C# 15 são discriminated unions?

Não exatamente, pois são uniões de tipos, e não casos rotulados como em F#. Ainda assim, ao criar um record novo para cada caso, você obtém na prática o mesmo efeito de uma discriminated union.

Qual versão do .NET preciso para usar unions?

Você precisa do SDK do .NET 11 e de um projeto com target net11.0. A partir do RC1, o C# 15 já é a versão padrão da linguagem nesse target.

Posso usar switch statement com unions?

Pode, mas apenas a switch expression recebe a verificação de exaustividade. Com switch statement, o compilador não garante que todos os casos tenham tratamento.

Por que o System.Text.Json não desserializa minha union?

Por padrão, o serializador escolhe o caso pelo tipo de token do JSON. Quando dois casos são objetos, a leitura fica ambígua e você precisa de um classificador ou de um conversor com discriminador.

Unions alocam memória?

A union em si é uma struct, mas guarda o valor em um object?, então casos de tipo valor sofrem boxing. Para cenários críticos, a documentação mostra como criar uma union personalizada sem boxing.