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:
// 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:
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:
// Before C# 12
var combined = first.Concat(second).ToArray();
// C# 12
int[] combined = [..first, ..second];
You can mix spread elements with individual values:
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:
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:
// 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:
[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:
- Known-length collections are allocated as a single buffer with no resizing.
- Span targets with constant data can be backed by static memory.
- Empty expressions reuse cached instances.
- Spread operations compute the total size upfront when possible.
// 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:
// 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.