Collection Expressions Deep Dive: Beyond the Basics

C# 12 introduced collection expressions — a unified syntax for creating collections that replaces the patchwork of new List<T>, array initialisers, and Enumerable.Range calls that have cluttered our code for years. On the surface they look like simple syntactic sugar, but there is genuine depth here worth exploring.

The Unified Syntax

Before collection expressions, creating collections in C# involved choosing between several inconsistent syntaxes:

Example.cs
// The old world
int[] array = new int[] { 1, 2, 3 };
List<int> list = new List<int> { 1, 2, 3 };
Span<int> span = stackalloc int[] { 1, 2, 3 };
ImmutableArray<int> immutable = ImmutableArray.Create(1, 2, 3);

Collection expressions unify all of this behind square brackets:

Example.cs
// The new world
int[] array = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];
ImmutableArray<int> immutable = [1, 2, 3];

The target type determines what gets created. The compiler is smart enough to pick the most efficient construction strategy for each type.

The Spread Operator

The .. spread operator is where collection expressions become genuinely powerful. It flattens one collection into another:

Example.cs
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
int[] combined = [..first, ..second]; // [1, 2, 3, 4, 5, 6]

This replaces the Concat plus ToArray pattern that was both verbose and allocation-heavy:

Example.cs
// Before: allocates an iterator and a new array
int[] combined = first.Concat(second).ToArray();

You can mix spread elements with individual values freely:

Example.cs
int[] padded = [0, ..values, int.MaxValue];

Spread works with any enumerable, so you can flatten LINQ queries, method results, or any IEnumerable<T> directly into a collection expression.

Empty Collections

The empty collection expression [] is a small but meaningful improvement. It replaces Array.Empty<T>(), Enumerable.Empty<T>(), and new List<T>() depending on context:

Example.cs
public IReadOnlyList<string> GetErrors()
{
    if (_isValid)
        return []; // No allocation — compiler can optimise this

    return [..CollectErrors()];
}

When the target type is an array, the compiler can emit Array.Empty<T>() behind the scenes, avoiding unnecessary allocations.

What the Compiler Actually Does

Understanding the lowered code helps explain why collection expressions are not merely syntactic sugar. Consider this example:

Example.cs
List<int> numbers = [1, 2, 3, 4, 5];

Rather than creating a list and calling Add five times, the compiler can use CollectionsMarshal.SetCount and write directly into the list's backing array via a span. This avoids repeated capacity checks and produces tighter code than you would typically write by hand.

For Span<T> targets, the compiler can stack-allocate when the size is known at compile time:

Example.cs
Span<int> span = [1, 2, 3]; // May use stackalloc internally

Natural Type

Collection expressions do not have a natural type — they require a target type. This means you cannot write:

Example.cs
var items = [1, 2, 3]; // Error: no target type

You must provide a type, either explicitly or through context like method parameters:

Example.cs
void Process(List<int> items) { }
Process([1, 2, 3]); // Target type inferred from parameter

This is a deliberate design decision. Since [1, 2, 3] could reasonably be an array, a list, a span, or an immutable array, forcing an explicit target avoids surprises.

Custom Collection Support

Your own types can participate in collection expressions by implementing IEnumerable<T> and providing an Add method, or by using the [CollectionBuilder] attribute for more control:

Example.cs
[CollectionBuilder(typeof(TokenList), nameof(Create))]
public class TokenList : IEnumerable<Token>
{
    private readonly Token[] _tokens;

    private TokenList(Token[] tokens) => _tokens = tokens;

    public static TokenList Create(ReadOnlySpan<Token> tokens)
        => new(tokens.ToArray());

    public IEnumerator<Token> GetEnumerator()
        => ((IEnumerable<Token>)_tokens).GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

// Now this works:
TokenList tokens = [new Token("if"), new Token("("), new Token("true")];

The CollectionBuilder attribute gives you full control over construction, including the ability to use ReadOnlySpan<T> for zero-copy initialisation of your backing store.

When to Use Them

Collection expressions should be your default choice for creating collections in new code. They are more concise, often more performant, and consistently readable. The main cases where you might still reach for the old syntax are when you need var type inference or when constructing a collection with specific capacity hints via the constructor.

Adopting collection expressions across a codebase is a low-risk modernisation step — the compiler does the heavy lifting, and the intent of the code becomes clearer.