Collection expressions no C# 15: como usar o with(...)

Collection expressions no C# 15: como usar o with(...)

Collection expressions ganharam um recurso novo no C# 15, que permite configurar a coleção sem abandonar a sintaxe com colchetes. Com o elemento with(...), você passa argumentos para o construtor ou para o método de criação da coleção dentro da própria expressão.

Neste artigo, você vai entender qual problema esse recurso resolve, como ele funciona por baixo dos panos e quais regras o compilador aplica. Além disso, vamos ver exemplos práticos com HashSet, List, interfaces e tipos personalizados.

O que são collection expressions

As collection expressions chegaram no C# 12 como uma forma curta e consistente de criar coleções. Em vez de escolher entre new[] { }, new List<T> { } ou métodos de fábrica, você escreve os elementos entre colchetes e deixa o compilador montar o resultado.

Na prática, a mesma sintaxe funciona para arrays, listas, spans e diversos outros tipos:

int[] numbers = [1, 2, 3];
List<string> names = ["Alice", "Bob", "Carol"];
Span<char> letters = ['a', 'b', 'c'];

Esse comportamento acontece porque uma collection expression não tem tipo próprio. Em outras palavras, o compilador olha para o tipo alvo da variável, do parâmetro ou do retorno e converte a expressão para ele.

Além disso, o operador de spread (..) permite combinar coleções existentes dentro de uma nova. Por exemplo, [.. first, .. second] cria uma coleção com os itens das duas origens, sem chamadas extras a AddRange ou Concat.

Por isso, muita gente passou a usar collection expressions como padrão em quase todo o código. Contudo, havia um cenário em que a sintaxe simplesmente não ajudava.

O limite das collection expressions antes do C# 15

Até o C# 14, toda collection expression chamava o construtor sem parâmetros do tipo alvo. Assim, não havia como informar um comparer, uma capacidade inicial ou qualquer outra configuração da coleção.

O exemplo clássico é o HashSet<string>. Por padrão, ele compara strings de forma ordinal e sensível a maiúsculas, então valores que representam a mesma coisa viram itens diferentes:

HashSet<string> tags = ["dotnet", "DotNet", "DOTNET"];
// tags.Count == 3, pois cada variação é tratada como um item diferente

Na maioria dos sistemas, porém, essas três tags deveriam ser uma só. Para resolver isso, era preciso voltar ao construtor e ao inicializador de coleção tradicional:

var tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
    "dotnet",
    "DotNet",
    "DOTNET",
};
// tags.Count == 1

O código funciona, mas perde a clareza das collection expressions. Além disso, ele fica mais longo e mistura dois estilos de inicialização no mesmo projeto.

O mesmo vale para a capacidade de uma List<T>. Quando você já sabe que a lista vai receber milhares de itens, informar a capacidade evita realocações do array interno.

Porém, isso exigia uma configuração em dois passos:

List<int> buffer = new(capacity: 2048);
buffer.AddRange(seed);

Em resumo, sempre que a coleção precisava de configuração, as collection expressions deixavam de ser uma opção. É exatamente essa lacuna que o C# 15 fecha.

Como usar o with(...) em collection expressions

O C# 15 adiciona um novo tipo de elemento às collection expressions, chamado with(...). Ele deve ser o primeiro elemento dos colchetes, e seus argumentos seguem para o construtor ou para o método de criação da coleção.

A leitura fica bem natural, pois o código diz: crie esta coleção com estes argumentos, contendo estes elementos. Dessa forma, a configuração e os dados ficam juntos, no mesmo lugar.

Por baixo dos panos, o compilador traduz o with(...) para uma chamada comum. Assim, [with(10), .. values] em uma List<string> vira algo equivalente a new List<string>(10), seguido da inclusão normal dos elementos.

Vale destacar que o compilador usa overload resolution para escolher o construtor. Portanto, os argumentos passam pela mesma checagem de tipos de qualquer chamada de método, e um erro de digitação vira erro de compilação.

Passando um comparer para um HashSet

Voltando ao exemplo das tags, agora basta colocar o comparer dentro do with(...):

HashSet<string> tags = [with(StringComparer.OrdinalIgnoreCase), "dotnet", "DotNet", "DOTNET"];
// tags.Count == 1

O resultado é idêntico ao da versão com construtor, mas o código continua sendo uma collection expression. Com isso, você mantém um único estilo de inicialização em todo o projeto.

O comparer não precisa ser estático nem conhecido em tempo de compilação. Na verdade, qualquer expressão válida pode ser usada como argumento, inclusive o retorno de um método:

public sealed class TagNormalizer
{
    private static IEqualityComparer<string> GetComparer(bool ignoreCase) =>
        ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;

    public HashSet<string> Normalize(IEnumerable<string> rawTags, bool ignoreCase = true)
    {
        // o comparer vem de um método e os itens vêm de um spread
        HashSet<string> tags = [with(GetComparer(ignoreCase)), .. rawTags];
        return tags;
    }
}

Nesse caso, o spread .. rawTags copia todos os itens da origem para o novo conjunto. Como o comparer já está configurado, as duplicatas com grafias diferentes são descartadas logo na inserção.

Definindo a capacidade de uma List

O segundo uso mais comum é a capacidade inicial. Quando você conhece o tamanho aproximado da coleção, pode reservar espaço de uma vez:

int[] seed = [1, 2, 3];
List<int> buffer = [with(capacity: 2048), .. seed];

Repare que o exemplo usa um argumento nomeado, capacity. Isso deixa claro o significado do número e também ajuda o compilador a escolher o construtor correto entre as sobrecargas de List<T>.

A documentação da Microsoft mostra um caso parecido, em que a capacidade depende de outra coleção:

string[] values = ["one", "two", "three"];
List<string> names = [with(capacity: values.Length * 2), .. values];

Aqui, a lista começa com o dobro do espaço necessário porque o código pretende adicionar mais itens depois. Assim, o array interno não precisa crescer nas próximas inserções.

Também é possível usar o with(...) para escolher outros construtores. Por exemplo, List<T> aceita um IEnumerable<T>, então [with([1, 2]), 3] cria uma lista com os itens 1, 2 e 3.

Regras do with em collection expressions

Para manter a sintaxe previsível, o elemento with(...) segue algumas regras. A primeira é que ele precisa ser o primeiro elemento da expressão e só pode aparecer uma vez.

Além disso, os argumentos não podem ter o tipo dynamic. Essa restrição existe porque argumentos dinâmicos exigiriam o binder de runtime para resolver a sobrecarga, o que complicaria a escolha do método de criação.

Outra regra importante envolve arrays e spans, que não aceitam with(...) nem mesmo vazio. Afinal, esses tipos não têm um construtor que faça sentido receber argumentos dessa forma:

int[] numbers = [with(), 1, 2, 3];      // erro: argumentos não suportados
Span<int> values = [with(), 1, 2, 3];   // erro: argumentos não suportados

Há também um detalhe sutil sobre inferência de tipos. Os argumentos dentro do with(...) não influenciam a conversão da collection expression nem a inferência de tipos genéricos, ou seja, o compilador considera apenas a presença do elemento.

Na prática, uma chamada como Print([with(comparer: null), 1, 2, 3]) não escolhe sozinha a sobrecarga que recebe HashSet<T>. Nesse cenário, você precisa deixar o tipo alvo explícito, assim como já acontece com o new() sem tipo.

Por outro lado, a presença do with(...) abre uma porta nova. Tipos cujos construtores exigem pelo menos um argumento passam a aceitar collection expressions, desde que a expressão inclua o with(...).

Quanto à ordem de avaliação, ela continua da esquerda para a direita. Primeiro o compilador avalia os argumentos do with(...) e depois os elementos, sempre uma única vez cada.

Interfaces como tipo alvo

Collection expressions também funcionam com interfaces, e o with(...) acompanha esse comportamento com uma lista curada de assinaturas. Para interfaces somente leitura, como IEnumerable<T>, IReadOnlyCollection<T> e IReadOnlyList<T>, apenas o with() vazio é permitido.

Já para ICollection<T> e IList<T>, você pode informar a capacidade. Nesse caso, o compilador cria uma List<T> com o construtor correspondente:

IList<string> names = [with(capacity: 20), "Alice", "Bob"];
// equivalente a new List<string>(20) seguido da inclusão dos itens

IReadOnlyList<string> readOnlyNames = [with(), "Alice", "Bob"];
// with() vazio tem o mesmo efeito de não usar with

Se você tentar passar argumentos para uma interface somente leitura, o compilador recusa o código. Isso evita a falsa impressão de que a configuração teria algum efeito na coleção gerada.

Erros de compilação mais comuns

Como o recurso é novo, vale conhecer os diagnósticos que o compilador emite. Segundo a lista oficial de erros de expressões de coleção no Microsoft Learn, os principais são:

  • CS9354: o with(...) não é o primeiro elemento ou aparece mais de uma vez.
  • CS9355: o tipo alvo não suporta argumentos, como acontece com arrays e spans.
  • CS9356: algum argumento tem o tipo dynamic.
  • CS9357: uma interface somente leitura recebeu argumentos no with(...).
  • CS9359: nenhum construtor ou método de criação aceita a quantidade de argumentos informada.

Na maioria dos casos, a correção é direta. Basta mover o with(...) para o início, converter o argumento dinâmico para o tipo correto ou ajustar os argumentos a uma sobrecarga existente.

Collection expressions com collection builder

Nem toda coleção é criada por um construtor. Tipos imutáveis e coleções personalizadas costumam usar o atributo CollectionBuilderAttribute, que aponta para um método estático de criação.

A partir do C# 15, esse método pode receber parâmetros adicionais antes do ReadOnlySpan<T> com os elementos. Dessa forma, os argumentos do with(...) chegam primeiro, e o span com os itens é sempre o último parâmetro:

[CollectionBuilder(typeof(TagSetBuilder), nameof(TagSetBuilder.Create))]
public sealed class TagSet<T> : IEnumerable<T>
{
    private readonly HashSet<T> _items;

    internal TagSet(ReadOnlySpan<T> items, IEqualityComparer<T>? comparer)
    {
        _items = new HashSet<T>(comparer);
        foreach (var item in items)
            _items.Add(item);
    }

    public int Count => _items.Count;

    public IEnumerator<T> GetEnumerator() => _items.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

public static class TagSetBuilder
{
    // usado quando a expressão não tem with(...)
    public static TagSet<T> Create<T>(ReadOnlySpan<T> items) =>
        new(items, null);

    // usado quando a expressão informa um comparer no with(...)
    public static TagSet<T> Create<T>(IEqualityComparer<T> comparer, ReadOnlySpan<T> items) =>
        new(items, comparer);
}

Com essa estrutura, o tipo personalizado ganha a mesma ergonomia das coleções da BCL:

TagSet<string> tags = [with(StringComparer.OrdinalIgnoreCase), "A", "a", "B"];
// tags.Count == 2

Nesse exemplo, o compilador gera uma chamada para TagSetBuilder.Create, passando o comparer como primeiro argumento e um span com os três itens no fim. Assim como nos construtores, a escolha entre as sobrecargas de Create também usa overload resolution.

Essa decisão de manter o span por último foi discutida pelo time da linguagem. Com isso, o método de criação pode declarar o span como params, o que permite chamá-lo diretamente em forma expandida quando for conveniente.

Para quem mantém bibliotecas, essa é uma das partes mais relevantes do recurso. Afinal, qualquer coleção personalizada que precise de configuração passa a oferecer suporte completo a collection expressions.

Collection expressions e dicionários

Curiosamente, a motivação original do with(...) veio dos dicionários. A proposta de dictionary expressions precisava de uma forma de informar o comparer das chaves, e o time da linguagem preferiu criar um mecanismo genérico para qualquer argumento.

No entanto, as dictionary expressions são uma proposta separada que ainda não foi entregue no RC do .NET 11. Por isso, hoje não existe uma forma de usar with(...) para configurar o comparer de um Dictionary<TKey, TValue> com colchetes.

Até lá, o construtor continua sendo a ferramenta certa para criar um dicionário que ignore maiúsculas:

var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
    ["Content-Type"] = "application/json",
    ["Accept"] = "application/json",
};

A especificação já descreve como o with(...) deve se comportar quando os dicionários chegarem. Segundo o texto, IDictionary<K, V> deve aceitar capacidade e comparer, enquanto IReadOnlyDictionary<K, V> deve aceitar apenas o comparer.

Portanto, vale acompanhar as próximas versões da linguagem. Quando o recurso chegar, as collection expressions vão cobrir praticamente todos os tipos de coleção da BCL.

Como habilitar o C# 15 no projeto

O C# 15 acompanha o .NET 11. Enquanto o recurso estiver em preview, é preciso informar a versão da linguagem no arquivo do projeto:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <LangVersion>preview</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

Além do SDK do .NET 11, você pode testar o recurso com a versão mais recente do Visual Studio 2026 Insiders. Depois de salvar o arquivo, o compilador passa a reconhecer o with(...) dentro das collection expressions.

Existe ainda um ponto de compatibilidade. Antes do C# 15, [with(x, y), z] era uma coleção com dois elementos, sendo o primeiro o resultado de um método chamado with.

Para não quebrar esse código, o compilador mantém o comportamento antigo quando o projeto usa uma versão anterior da linguagem. E, se você realmente tiver um método chamado with, basta chamá-lo como @with(...) para evitar a ambiguidade.

Se você acompanha as novidades do .NET 11, vale ler também o artigo sobre como evitar alocações com o StringBuilder.MoveChunks. Ele mostra outro recurso da versão focado em código mais eficiente.

Quando usar argumentos nas collection expressions

A regra prática é simples: use with(...) quando a coleção, e não apenas o seu conteúdo, precisar de configuração. Para todo o resto, os colchetes simples continuam sendo a melhor escolha.

Na maioria dos projetos, isso aparece em três situações:

  • Conjuntos que dependem de comparação, como um HashSet<string> que ignora maiúsculas.
  • Listas grandes em que a capacidade inicial é conhecida.
  • Coleções personalizadas que expõem um CollectionBuilderAttribute com parâmetros de configuração.

Por outro lado, evite usar o with(...) só por estilo. Um with() vazio, por exemplo, não muda nada no resultado e apenas adiciona ruído à leitura.

Também vale cuidado com a capacidade. Informar um valor muito maior que o necessário desperdiça memória, então use o recurso quando houver uma estimativa razoável do tamanho final.

Em equipes, outra boa prática é padronizar o uso. Se o projeto adotou collection expressions como estilo principal, o with(...) elimina a última exceção comum e deixa o código mais uniforme.

Nesse sentido, regras de estilo do analisador podem ajudar. Assim, o time recebe sugestões para trocar inicializadores antigos por collection expressions sempre que a troca for segura.

Conclusão

As collection expressions resolveram boa parte da verbosidade na criação de coleções, mas ainda tinham um ponto cego. Sempre que era preciso configurar a coleção, o desenvolvedor voltava aos construtores e inicializadores tradicionais.

Com o C# 15, o elemento with(...) fecha essa lacuna. Agora, comparers, capacidade e outros argumentos ficam dentro dos colchetes, junto com os dados.

Além disso, o recurso foi desenhado para ser previsível. Ele vem sempre primeiro, passa pela checagem normal de sobrecargas e respeita a ordem de avaliação da esquerda para a direita.

Tipos personalizados também se beneficiam, graças aos novos parâmetros nos métodos de criação. Dessa forma, bibliotecas inteiras podem oferecer a mesma experiência das coleções nativas.

Por enquanto, lembre de configurar o LangVersion como preview e de manter o construtor para dicionários. Assim que o .NET 11 estabilizar, as collection expressions com argumentos devem virar o padrão para quem busca um código mais expressivo.

Para aprofundar, a documentação oficial sobre as novidades do C# 15 traz todos os detalhes. Então, teste os exemplos deste artigo no seu projeto e veja o quanto o código fica mais limpo.

FAQ

O que é o with(...) nas collection expressions?

É um elemento novo do C# 15 que passa argumentos para o construtor ou para o método de criação da coleção. Ele precisa ser o primeiro elemento dentro dos colchetes.

Posso usar with(...) com arrays ou Span?

Não. Arrays e spans não aceitam argumentos, e até um with() vazio gera erro de compilação.

O with(...) funciona com Dictionary?

Ainda não, porque as dictionary expressions são uma proposta separada. Para um dicionário com comparer, continue usando o construtor.

Preciso de alguma configuração para usar o recurso?

Sim, enquanto o C# 15 estiver em preview é preciso definir LangVersion como preview no projeto. Também é necessário usar o SDK do .NET 11.

O with(...) deixa o código mais lento?

Não, porque o compilador traduz o elemento para uma chamada comum ao construtor ou ao método de criação. Na verdade, informar a capacidade pode até evitar realocações.