Collection Expressions in C# 12

Before C# 12, creating collections required different syntax for every type. Arrays used new[], lists needed new List<T>, and immutable collections had their own factory methods. Collection expressions unify all of this behind a single, clean syntax using square brackets.

The New Syntax

Collection expressions use [...] to initialise any supported collection type:

Example.cs
// Arrays
int[] numbers = [1, 2, 3, 4, 5];

// Lists
List<string> names = ["Alice", "Bob", "Charlie"];

// Spans
Span<byte> bytes = [0x00, 0xFF, 0xAB];

// Immutable arrays
ImmutableArray<int> immutable = [10, 20, 30];

// HashSets
HashSet<string> tags = ["csharp", "dotnet", "performance"];

The compiler chooses the optimal creation strategy for each target type. An array literal compiles to a direct array allocation. A Span<byte> with constant values can be backed by static data, avoiding allocation entirely.

The Spread Operator

The .. spread operator lets you inline 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 Concat, AddRange, and manual copying patterns:

Example.cs
// Before C# 12
var combined = first.Concat(second).ToArray();

// C# 12
int[] combined = [..first, ..second];

You can mix spread elements with individual values:

Example.cs
int[] withBookends = [0, ..numbers, 99];

The compiler computes the total length when possible and allocates a single buffer, making this more efficient than chaining LINQ calls.

Empty Collections

Creating empty collections is now trivial:

Example.cs
int[] empty = [];
List<string> noNames = [];
ImmutableArray<int> nothing = [];

For arrays, [] is equivalent to Array.Empty<T>() — the compiler uses a cached instance rather than allocating a new empty array each time.

Natural Type

Collection expressions do not have a natural type on their own. You must provide a target type through declaration, a return type, or a cast:

Example.cs
// Target type from the variable declaration
List<int> items = [1, 2, 3];

// Target type from the method return type
public int[] GetDefaults() => [0, 0, 0];

// Target type from a parameter
PrintNames(["Alice", "Bob"]);
void PrintNames(IReadOnlyList<string> names) { }

This means var x = [1, 2, 3]; does not compile — you must specify the collection type.

Custom Collection Support

Your own types can participate in collection expressions by implementing IEnumerable<T> and providing an Add method, or by applying the CollectionBuilder attribute:

Example.cs
[CollectionBuilder(typeof(ValueBuffer), nameof(Create))]
public readonly struct ValueBuffer<T> : IEnumerable<T>
{
    private readonly T[] _items;

    internal ValueBuffer(T[] items) => _items = items;

    public static ValueBuffer<T> Create(ReadOnlySpan<T> items)
        => new(items.ToArray());

    public IEnumerator<T> GetEnumerator() =>
        ((IEnumerable<T>)(_items ?? [])).GetEnumerator();

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

// Now you can use collection expressions:
ValueBuffer<int> buffer = [1, 2, 3];

The CollectionBuilder attribute tells the compiler which factory method to call, and the method receives a ReadOnlySpan<T> for efficient element passing.

Performance Characteristics

The compiler applies several optimisations:

Example.cs
// This allocates a single array of length 6, not two arrays
int[] a = [1, 2, 3];
int[] b = [..a, 4, 5, 6];

Replacing Older Patterns

Collection expressions make several older patterns obsolete:

Example.cs
// Old: new[] { 1, 2, 3 }
int[] numbers = [1, 2, 3];

// Old: new List<string> { "a", "b" }
List<string> letters = ["a", "b"];

// Old: Array.Empty<int>()
int[] empty = [];

// Old: Enumerable.Empty<string>().ToList()
List<string> emptyList = [];

// Old: first.Concat(second).ToArray()
int[] merged = [..first, ..second];

Collection expressions bring consistency to a space that previously required memorising different syntax for every collection type. They are shorter, clearer, and in many cases compile to more efficient code than the patterns they replace.