A maior e melhor Black Friday que já fizemos! Descontos a partir de 50% em até 12x sem juros - Garanta seu Desconto

C# 15: union types e outras novidades

C# 15: union types e outras novidades

O C# 15 chega com o .NET 11, lançado em 10 de novembro de 2026 na .NET Conf, e resolve um problema antigo de quem usa pattern matching: o compilador finalmente sabe quais tipos um valor pode assumir. Neste guia você vai entender as union types, as closed hierarchies, os extension indexers, os argumentos em collection expressions e o labeled break e continue, além de decidir se vale atualizar seus projetos agora.

O que muda com o C# 15 no .NET 11

O C# 15 é a versão padrão da linguagem para projetos que usam o target net11.0. Por isso, basta atualizar o SDK e o TargetFramework para começar a usar quase tudo o que a versão oferece.

Segundo a página oficial de novidades do C# na documentação da Microsoft, a lista completa de recursos inclui:

  • Argumentos em collection expressions, com o elemento with(...).
  • Union types, com a palavra-chave union.
  • Closed hierarchies, com o modificador closed.
  • Extension indexers dentro de blocos extension.
  • Labeled break e continue.
  • Membros estáticos não virtuais em interfaces para runtimes mais antigos.
  • Memory safety, ainda como recurso em preview.

Além disso, apenas o memory safety exige <LangVersion>preview</LangVersion>. Todos os outros recursos funcionam com a configuração padrão do projeto.

Neste artigo, os exemplos usam um catálogo de produtos de uma loja virtual. Assim, cada recurso aparece resolvendo um problema concreto, e não apenas uma sintaxe isolada.

O problema que o C# 15 resolve

Considere um record abstrato Product e dois records que derivam dele: BookProduct e ElectricalProduct. Cada produto tem uma regra própria de imposto.

public abstract record Product(int Id, string Name, decimal Price);

public record BookProduct(int Id, string Name, decimal Price, string Author)
    : Product(Id, Name, Price);

public record ElectricalProduct(int Id, string Name, decimal Price, int WarrantyMonths)
    : Product(Id, Name, Price);

Em seguida, um método CalculateTax usa uma switch expression para calcular o imposto de acordo com o tipo do produto.

public static class ProductExtensions
{
    public static decimal CalculateTax(Product product)
        => product switch
        {
            BookProduct => 0m,
            ElectricalProduct electrical => electrical.Price * 0.2m,
            // O compilador não sabe que só existem esses dois tipos
            _ => throw new ArgumentOutOfRangeException(nameof(product))
        };
}

Repare no braço de descarte _ no fim do switch. Ele existe porque qualquer tipo pode herdar de Product, então o compilador não consegue provar que você tratou todos os casos.

O problema aparece depois. Quando alguém cria um ClothingProduct e esquece de atualizar o CalculateTax, a aplicação compila normalmente.

public record ClothingProduct(int Id, string Name, decimal Price, string Size)
    : Product(Id, Name, Price);

Nesse caso, o erro só surge em produção, na forma de uma ArgumentOutOfRangeException. Em outras palavras, um erro de modelagem que deveria travar o build vira uma falha em tempo de execução.

Union types no C# 15

As union types são a principal novidade do C# 15. Uma union declara que um valor é exatamente um tipo dentro de uma lista fechada de tipos, chamados de case types.

Na prática, isso entrega ao compilador a informação que faltava no exemplo anterior. Dessa forma, ele consegue verificar se uma switch expression cobre todas as possibilidades.

Comparação entre uma hierarquia aberta, em que qualquer tipo pode herdar de Product e o switch precisa de descarte, e uma union do C# 15 com lista fechada de casos e switch exaustivo
Com a herança aberta, o compilador não conhece todos os casos; com a union, a lista de casos é fechada e verificável.

Declarando uma union

A declaração usa a palavra-chave union, seguida do nome e dos case types entre parênteses. No exemplo da loja, a union StockedProduct aceita um livro ou um produto elétrico.

public union StockedProduct(BookProduct, ElectricalProduct);

Com isso, o CalculateTax passa a receber um StockedProduct. Como o compilador conhece todos os casos, o braço de descarte some.

public static class UnionTypes
{
    public static decimal CalculateTax(StockedProduct product)
        => product switch
        {
            BookProduct => 0m,
            ElectricalProduct electrical => electrical.Price * 0.2m,
        };
}

Além disso, cada case type converte implicitamente para a union. Portanto, você atribui um BookProduct direto a uma variável StockedProduct, sem cast nem método de fábrica.

StockedProduct product = new BookProduct(1, "Clean Code", 89.90m, "Robert C. Martin");
decimal tax = UnionTypes.CalculateTax(product);

Os type patterns dentro do switch atuam sobre o conteúdo da union, e não sobre a union em si. Por isso, BookProduct => 0m compara o valor guardado, exatamente como você esperaria.

Transformando o aviso CS8509 em erro

Agora considere a mesma evolução do catálogo, com a entrada do ClothingProduct na union. Assim que você adiciona o novo caso, o compilador avisa que o switch deixou de ser exaustivo.

public union StockedProduct(BookProduct, ElectricalProduct, ClothingProduct);

O aviso é o CS8509, que indica que a switch expression não trata todos os valores possíveis. A mensagem inclusive aponta o padrão ClothingProduct como o caso esquecido.

Contudo, um aviso ainda deixa a aplicação compilar. Para travar o build de verdade, adicione o CS8509 à propriedade WarningsAsErrors do arquivo .csproj.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <WarningsAsErrors>CS8509</WarningsAsErrors>
  </PropertyGroup>
</Project>

A partir daí, o projeto só compila depois que você trata o novo caso no switch.

public static decimal CalculateTax(StockedProduct product)
    => product switch
    {
        BookProduct => 0m,
        ElectricalProduct electrical => electrical.Price * 0.2m,
        ClothingProduct clothing => clothing.Price * 0.1m
    };

Esse é o ganho central do C# 15 para quem trabalha com regras de negócio. O erro sai da execução e volta para o momento do build, onde custa muito menos.

Tipos sem classe base em comum

Outra vantagem é que os case types não precisam herdar do mesmo tipo. Você pode remover a herança de Product do ClothingProduct e manter o tipo dentro da StockedProduct.

Isso abre espaço para unions de tipos que nunca compartilhariam uma hierarquia. Um exemplo comum na documentação é um resultado que guarda um valor ou uma exceção.

public union Result<T>(T, Exception);

public static string Describe<T>(Result<T> result) => result switch
{
    T value => $"Success: {value}",
    Exception error => $"Failure: {error.Message}",
    null => "Failure: no value",
};

Até então, esse tipo de modelagem dependia de bibliotecas como OneOf ou de classes com flags de sucesso. Agora, a própria linguagem resolve o cenário com uma linha.

Como a union funciona por baixo dos panos

Uma declaração union gera uma struct que guarda o conteúdo em um único campo do tipo object?. Por consequência, case types que são value types sofrem boxing ao entrar na union.

Como a union é uma struct, o valor padrão dela tem conteúdo nulo. Por isso, quando algum case type aceita null ou quando você trabalha com default, o switch precisa de um braço null.

Para cenários sensíveis a performance, o C# 15 aceita unions customizadas. Basta aplicar o atributo [Union] a uma struct e implementar o padrão de acesso que o compilador espera, com HasValue e sobrecargas de TryGetValue.

Assim, bibliotecas que já ofereciam tipos parecidos com unions ganham o mesmo suporte do compilador. Elas não precisam migrar para a nova sintaxe para ter pattern matching exaustivo.

Closed hierarchies no C# 15

O segundo recurso de exaustividade do C# 15 é o modificador closed. Ao aplicá-lo a uma classe ou record, apenas tipos do mesmo assembly podem herdar dele.

// Projeto Catalog
public closed record Product(int Id, string Name, decimal Price);

public record HomeProduct(int Id, string Name, decimal Price, string Room)
    : Product(Id, Name, Price);

Agora suponha que outro projeto, como um Storefront, tente criar um AutomotiveProduct herdando de Product. Nesse caso, a compilação falha com o erro CS9382.

// Projeto Storefront: não compila
public record AutomotiveProduct(int Id, string Name, decimal Price, string Manufacturer)
    : Product(Id, Name, Price);

Como o compilador conhece todos os descendentes diretos, um switch que trata cada um deles já é exaustivo. Logo, o braço de descarte também deixa de ser necessário aqui.

Algumas regras merecem atenção. Uma classe closed é implicitamente abstrata e não aceita os modificadores sealed, static ou abstract explícito.

A restrição também não é transitiva. Um descendente que não seja closed ainda pode receber herança de outros assemblies, então marque os níveis intermediários quando quiser estender a verificação.

Combinando closed com union types

Os dois recursos se complementam. Se alguém em outro assembly herdar de BookProduct, a union vai tratar esse tipo novo como um BookProduct, sem nenhum aviso.

Às vezes, esse comportamento não é o desejado. Ao marcar os case types como closed, você garante que conhece todos os tipos que podem chegar até a union.

O cenário em que essa combinação mais brilha é o de pacotes NuGet. Afinal, quem consome o pacote recebe uma lista completa e estável dos valores possíveis.

Quando usar cada recurso do C# 15

Com dois recursos de exaustividade disponíveis, surge a dúvida sobre qual escolher. A decisão depende principalmente de quem é dono dos tipos e de como eles se relacionam.

Use uma union quando os casos já existem e não compartilham uma hierarquia. Por exemplo, um resultado que pode ser um valor ou uma exceção, ou tipos que vêm de bibliotecas diferentes.

Prefira uma closed hierarchy quando os tipos formam uma família com dados e comportamentos em comum. Nesse caso, a classe base continua útil, e o modificador closed apenas fecha a porta para herança externa.

O enum continua sendo a melhor opção para constantes nomeadas sem dados associados. Já a herança aberta segue válida quando você quer, de propósito, permitir que outros assemblies adicionem novos tipos, como em uma arquitetura de plugins.

Um uso que tende a se popularizar é o resultado de operações em APIs. Uma operação de criação de pedido, por exemplo, pode devolver uma union com os casos de pedido criado, pedido inválido e produto não encontrado.

public record OrderCreated(Guid OrderId);
public record InvalidOrder(string Reason);
public record ProductNotFound(int ProductId);

public union CreateOrderResult(OrderCreated, InvalidOrder, ProductNotFound);

Desse modo, quem chama a operação precisa tratar cada desfecho de forma explícita. Quando surgir um caso novo, como estoque insuficiente, o compilador aponta todos os switches que precisam mudar.

O ecossistema do .NET 11 também acompanha a novidade. O System.Text.Json ganhou suporte para serializar union types, e o ASP.NET Core aceita unions no corpo de requisições de Minimal APIs e de controllers MVC.

No entanto, a vinculação de unions a partir de rota, query string, cabeçalhos e formulários ainda não é suportada. Então, por enquanto, mantenha esse tipo de parâmetro com tipos simples.

Extension indexers

O C# 14 trouxe os blocos extension, que agrupam extension members com o mesmo receptor. Por exemplo, você reúne métodos de busca sobre IEnumerable<Product> em um só lugar.

public static class ProductExtensions
{
    extension(IEnumerable<Product> products)
    {
        public Product? GetById(int id)
            => products.FirstOrDefault(p => p.Id == id);

        public IReadOnlyList<Product> HigherThanPrice(decimal price)
            => products.Where(p => p.Price > price).ToList();
    }
}

O C# 15 completa essa história com os extension indexers. Agora, um bloco extension também pode declarar um indexador com this[...].

public static class ProductExtensions
{
    extension(IEnumerable<Product> products)
    {
        // Indexador de extensão: busca pelo nome
        public Product? this[string name]
            => products.FirstOrDefault(p => p.Name == name);
    }
}

IEnumerable<Product> catalog = LoadCatalog();
Product? book = catalog["Clean Code"];

Como indexadores são sempre membros de instância, o bloco precisa nomear o parâmetro receptor. Em troca, você indexa a coleção como se o indexador existisse no próprio tipo.

Argumentos em collection expressions

As collection expressions chegaram no C# 12 e trouxeram o spread operator. Com elas, você junta coleções de tipos diferentes em uma nova coleção com uma sintaxe curta.

public static IList<Product> AddProduct(Product[] products, Product newProduct)
    => [.. products, newProduct];

Faltava, porém, uma forma de passar argumentos para o construtor da coleção. O C# 15 resolve isso com um elemento with(...), que deve ser o primeiro da expressão.

public static IList<Product> AddProductWithCapacity(Product[] products, Product newProduct)
    => [with(capacity: products.Length + 1), .. products, newProduct];

// Também funciona com comparadores
HashSet<string> tags = [with(StringComparer.OrdinalIgnoreCase), "dotnet", "DOTNET", "csharp"];

No primeiro método, a lista já nasce com a capacidade certa e evita realocações. Já no HashSet, o comparador ignora maiúsculas, então "dotnet" e "DOTNET" contam como um único elemento.

Labeled break e continue no C# 15

Loops aninhados costumam exigir uma variável de controle para sair do loop externo. No exemplo abaixo, um pedido só segue para expedição quando todos os produtos estão em estoque.

public static void DispatchOrders(IList<Order> orders, Action<Order> dispatch)
{
    foreach (var order in orders)
    {
        var inStock = true;

        foreach (var product in order.Products)
        {
            if (!product.InStock)
            {
                inStock = false;
                break;
            }
        }

        if (!inStock)
        {
            continue;
        }

        dispatch(order);
    }
}

Com o C# 15, você coloca um rótulo no loop externo e usa esse rótulo no continue. Assim, o fluxo pula direto para o próximo pedido, sem flag e sem goto.

public static void DispatchOrders(IList<Order> orders, Action<Order> dispatch)
{
    nextOrder: foreach (var order in orders)
    {
        foreach (var product in order.Products)
        {
            if (!product.InStock)
            {
                continue nextOrder;
            }
        }

        dispatch(order);
    }
}

O rótulo fica diretamente sobre o loop ou sobre o switch que ele identifica. Sem rótulo, break e continue mantêm o comportamento de sempre e atuam no laço mais interno.

Além disso, a regra de estilo IDE0410 aponta os padrões com flag e com goto que podem virar um salto rotulado. Ainda assim, vale avaliar se uma consulta LINQ não deixaria o código mais claro do que um loop aninhado.

Outras novidades da versão

O C# 15 também permite declarar membros estáticos não virtuais em interfaces mesmo em runtimes sem suporte a implementações padrão. Com isso, uma interface pode oferecer um método auxiliar estático sem exigir nada das classes que a implementam.

public interface IMeasurement
{
    public static double ToMeters(double centimeters) => centimeters / 100;
}

Por fim, o memory safety inicia um esforço de várias versões para redefinir a segurança de memória na linguagem. A ideia é ligar o contexto unsafe às operações que acessam memória não gerenciada, e não à simples existência de ponteiros.

Esse recurso continua em preview. Portanto, ele exige LangVersion igual a preview e não deve entrar em código de produção por enquanto.

Vale a pena atualizar para o .NET 11

O .NET 11 é uma versão STS, com suporte de dois anos, até novembro de 2028. Antes, as versões STS tinham apenas 18 meses de suporte.

Por outro lado, o .NET 10 é LTS e também perde o suporte em novembro de 2028. Portanto, migrar uma aplicação estável do .NET 10 para o .NET 11 significa fazer duas atualizações em vez de uma.

Atualizar um projeto não se resume a trocar o target. Você também atualiza pacotes NuGet, revisa breaking changes e roda uma bateria de testes, o que consome tempo.

Assim, para aplicações existentes no .NET 10, os recursos do C# 15 dificilmente justificam a migração sozinhos. Já para projetos novos, a escolha do .NET 11 faz sentido, porque o fim do suporte cai no mesmo mês.

A situação muda para quem ainda está no .NET 8 ou no .NET 9. Ambos perderam o suporte em 10 de novembro de 2026, no mesmo dia do lançamento do .NET 11, então a atualização já é obrigatória.

Usando o C# 15 com LangVersion no .NET 10

Com o SDK do .NET 11 instalado, você pode manter o projeto no net10.0 e definir LangVersion como 15.0. Nesse caso, porém, nem todos os recursos funcionam.

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <LangVersion>15.0</LangVersion>
</PropertyGroup>

Union types e closed hierarchies dependem de tipos do runtime, como IUnion, UnionAttribute e IsClosedTypeAttribute. Sem eles, o compilador retorna erros como CS0518 e CS0656.

Em resumo, recursos puramente sintáticos funcionam nesse arranjo, enquanto os recursos de exaustividade exigem o .NET 11. Se você quer as unions do C# 15, a atualização do runtime é inevitável.

Conclusão

O C# 15 entrega o que a comunidade pedia há anos: um jeito nativo de dizer ao compilador que um valor pertence a uma lista fechada de tipos. Com union types e closed hierarchies, o switch deixa de precisar do descarte e passa a apontar casos esquecidos no build.

Os demais recursos são menores, mas removem atrito do dia a dia. Extension indexers, argumentos em collection expressions e labeled break e continue deixam o código mais direto.

Se você já usa records e pattern matching, como mostramos no artigo sobre primary constructors no C# 12, a adoção será natural. Comece pelos projetos novos e mantenha as aplicações estáveis no .NET 10 até o próximo LTS.

FAQ

O que são union types no C# 15?

São tipos que representam um valor pertencente a uma lista fechada de case types. O compilador usa essa lista para verificar se um switch trata todos os casos.

Preciso do .NET 11 para usar union types?

Sim. As unions dependem de tipos do runtime, como IUnion e UnionAttribute, que só existem a partir do .NET 11.

Qual a diferença entre union e closed hierarchy?

A union agrupa tipos que já existem, mesmo sem herança em comum. A closed hierarchy restringe quem pode herdar de uma classe ao próprio assembly.

Os case types de uma union precisam herdar da mesma classe?

Não. Você pode combinar records, classes, structs e até tipos como string e Exception na mesma union.

Vale migrar uma aplicação do .NET 10 para o .NET 11?

Na maioria dos casos, não. As duas versões perdem suporte em novembro de 2028, então a migração costuma fazer sentido apenas para projetos novos ou fora de suporte.