You have used collection expressions hundreds of times since C# 12. The bracket syntax is clean, the spread operator is genuinely useful, and the compiler is smart enough to stack-allocate spans and avoid unnecessary copies. But there has always been an awkward gap: you cannot tell the collection how to construct itself. If you know you are about to pour ten thousand items into a list, you still cannot hint at a capacity. If you need a case-insensitive HashSet<string>, you have to drop back to new HashSet<string>(StringComparer.OrdinalIgnoreCase) and lose the bracket syntax entirely.

C# 15 closes that gap with a single addition: the with() element. Place it at the start of a collection expression and you can pass arguments — capacity, comparers, or anything else the constructor accepts — directly inside the brackets. No new types, no attributes on your code, just a keyword and a pair of parentheses.

The feature shipped in .NET 11 Preview 1 and is available now in Visual Studio 2026 Insiders. The syntax is finalised and the compiler support is solid, though a handful of edge cases around interface target types are still being refined.

The syntax

A with() element appears as the very first item inside a collection expression. Everything inside the parentheses maps to the target type's constructor parameters or factory method parameters:

Services/DataLoader.cs
public List<Customer> LoadAll(IReadOnlyList<Customer> cached, IReadOnlyList<Customer> fresh)
{
    // Hint the capacity so the list does not resize during population
    List<Customer> all = [with(capacity: cached.Count + fresh.Count), .. cached, .. fresh];
    return all;
}

The compiler translates that to a new List<Customer>(capacity) call followed by the normal element initialisation. Without the with(), the compiler would call the parameterless constructor and let the list resize as elements are added — fine for small collections, wasteful when you already know the count.

The same pattern works for comparers:

Example.cs
HashSet<string> tags = [with(StringComparer.OrdinalIgnoreCase), "Blazor", "blazor", "BLAZOR"];
// tags.Count == 1

And for dictionaries:

Example.cs
Dictionary<string, int> scores = [with(StringComparer.OrdinalIgnoreCase),
    new KeyValuePair<string, int>("Alice", 10),
    new KeyValuePair<string, int>("alice", 20)
];
// scores.Count == 1, value is 20

The rule is simple: with() must be the first element, and its arguments are resolved against the target type's constructors using normal overload resolution.

What the compiler actually does

Understanding the lowering helps you predict behaviour and performance. For types with constructors (the common case), the compiler rewrites the expression into a constructor call with the with() arguments, followed by element additions:

Example.cs
// What you write
List<int> numbers = [with(capacity: 100), 1, 2, 3];

// What the compiler emits (conceptually)
var __result = new List<int>(capacity: 100);
__result.Add(1);
__result.Add(2);
__result.Add(3);

For types decorated with [CollectionBuilder], the arguments are prepended to the factory method call, with the elements span always coming last:

Example.cs
[CollectionBuilder(typeof(ScoreSetBuilder), "Create")]
public class ScoreSet<T> : IEnumerable<T>
{
    // ...
}

public static class ScoreSetBuilder
{
    public static ScoreSet<T> Create<T>(
        IEqualityComparer<T> comparer,
        ReadOnlySpan<T> elements) => /* ... */;

    public static ScoreSet<T> Create<T>(
        ReadOnlySpan<T> elements) => /* ... */;
}
Services/Scoring.cs
ScoreSet<string> winners = [with(StringComparer.OrdinalIgnoreCase), "Alice", "Bob"];

// Lowered to:
// ScoreSetBuilder.Create<string>(StringComparer.OrdinalIgnoreCase, ["Alice", "Bob"]);

The compiler strips the last ReadOnlySpan<T> parameter when matching the with() arguments against available factory methods, so you only supply the extra parameters and the elements flow through automatically.

Practical scenarios

Pre-sizing lists from database queries

When you know the row count before materialising results, hinting the capacity avoids repeated array copies inside the list:

Repositories/OrderRepository.cs
public async Task<List<Order>> GetRecentOrdersAsync(int customerId)
{
    var count = await _db.Orders.CountAsync(o => o.CustomerId == customerId);
    var orders = await _db.Orders
        .Where(o => o.CustomerId == customerId)
        .OrderByDescending(o => o.CreatedAt)
        .ToListAsync();

    // Re-pack into a list with known capacity for downstream processing
    List<Order> result = [with(capacity: count), .. orders];
    return result;
}

// TIP

This matters most when the count is in the thousands. For small collections, the default capacity doubling strategy in List<T> is cheap enough that the with() adds noise without meaningful benefit.

Case-insensitive lookups with HashSet

Building a set of allowed origins for CORS, HTTP headers, or feature flags often requires case-insensitive comparison. Previously you had to choose between the bracket syntax and the comparer:

Example.cs
// Bad — loses the comparer
HashSet<string> origins = ["https://app.example.com", "https://staging.example.com"];

// Bad — loses the bracket syntax
var origins = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
    "https://app.example.com",
    "https://staging.example.com"
};

// Good — both in one expression
HashSet<string> origins = [with(StringComparer.OrdinalIgnoreCase),
    "https://app.example.com",
    "https://staging.example.com"
];

Dictionary with comparer and capacity

For dictionaries that receive HTTP headers or configuration keys, combining a comparer with a capacity hint keeps the expression self-contained:

Middleware/HeaderNormaliser.cs
Dictionary<string, string> defaults = [with(capacity: 4, StringComparer.OrdinalIgnoreCase),
    new("Content-Type", "application/json"),
    new("Accept", "application/json"),
    new("Cache-Control", "no-cache"),
    new("X-Request-Id", Guid.NewGuid().ToString())
];

Custom collection builders

If you maintain your own collection types with a [CollectionBuilder] attribute, you can opt into with() by adding overloads to your builder's factory method. The only constraint is that the ReadOnlySpan<T> parameter must be last:

Collections/CircularBuffer.cs
[CollectionBuilder(typeof(CircularBufferBuilder), "Create")]
public class CircularBuffer<T> : IEnumerable<T>
{
    private readonly T[] _items;
    private int _head;

    internal CircularBuffer(int capacity, ReadOnlySpan<T> initial)
    {
        _items = new T[capacity];
        initial.CopyTo(_items);
        _head = initial.Length;
    }

    // IEnumerable<T> implementation...
}

public static class CircularBufferBuilder
{
    public static CircularBuffer<T> Create<T>(int capacity, ReadOnlySpan<T> elements)
        => new(capacity, elements);

    public static CircularBuffer<T> Create<T>(ReadOnlySpan<T> elements)
        => new(elements.Length * 2, elements);
}
Example.cs
// Uses the two-parameter overload, pre-allocating 256 slots
CircularBuffer<byte> buffer = [with(capacity: 256), 0x00, 0xFF];

// Uses the single-parameter overload, capacity derived from element count
CircularBuffer<byte> buffer2 = [0x00, 0xFF];

This pattern makes your custom collections feel native. Consumers get the same bracket syntax they use for List<T> and HashSet<T>, with the same ability to pass construction arguments.

Interface target types

The with() element also works when the target type is an interface, though the set of accepted arguments is narrower. For mutable interfaces like IList<T> and IDictionary<K, V>, the compiler uses the constructors of List<T> and Dictionary<K, V> respectively:

Example.cs
IList<int> items = [with(capacity: 50), 1, 2, 3];
// Compiles to: new List<int>(capacity: 50) { 1, 2, 3 }

IDictionary<string, int> lookup = [with(StringComparer.OrdinalIgnoreCase)];
// Compiles to: new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)

For read-only interfaces like IReadOnlyDictionary<K, V>, only a comparer argument is supported — you cannot pass a capacity because the compiler synthesises its own backing type:

Example.cs
IReadOnlyDictionary<string, int> config = [with(StringComparer.OrdinalIgnoreCase),
    new("timeout", 30),
    new("retries", 3)
];

// WARNING

Arrays and spans do not support with() at all. Writing int[] a = [with(capacity: 10), 1, 2] is a compile-time error. There is no constructor to forward arguments to — arrays are fixed-size and spans are stack-allocated.

Common pitfalls

Putting with() in the wrong position. The with() element must be the very first item in the collection expression. Placing it after any other element is a compiler error:

Example.cs
// Bad — with() is not the first element
List<int> numbers = [1, 2, with(capacity: 10), 3]; // CS????

// Good
List<int> numbers = [with(capacity: 10), 1, 2, 3];

Confusing with() with an existing method call. If your codebase happens to have a method called with, the compiler will treat [with(...)] as a collection expression argument, not a method invocation. To call the method, escape the identifier: [@with(...)]. This is unlikely to bite you in practice — with violates .NET naming conventions for methods — but it is a breaking change from C# 14.

Using with() on unsupported target types. Arrays, Span<T>, and ReadOnlySpan<T> do not accept with(). Neither do types that lack constructors with matching parameters. The compiler error is clear, but it catches people who expect universal support.

Passing dynamic arguments. The with() element does not support dynamic-typed arguments. The compiler needs to resolve the constructor or factory method at compile time, so all arguments must have static types.

Over-optimising capacity. Pre-sizing a list to the exact element count is fine. Pre-sizing to a massive buffer "just in case" wastes memory and defeats the purpose. If you do not know the count, leave the with() out and let the list grow naturally.

Assuming with() changes element behaviour. The arguments in with() affect only construction — they do not filter, transform, or reorder the elements that follow. A comparer on a HashSet deduplicates at insertion time, but a comparer on a List is meaningless (lists do not deduplicate).

Summary