Collection expressions: syntax, target typing, CollectionBuilder, spread and performance behaviour.
A collection expression (C# 12) is [e1, e2, ..other]: a single syntax that creates arrays, List<T>, Span<T>, ReadOnlySpan<T>, immutable collections and more. It is target-typed: the type on the left (or the parameter type) decides what is built, so var x = [1, 2]; does not compile. Supported targets are arrays, spans, types with a collection initialiser (IEnumerable plus Add), types marked [CollectionBuilder] (such as ImmutableArray<T>), and the interfaces IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, ICollection<T> and IList<T>. .. spreads another collection's elements. The compiler picks efficient code: [] to an array uses Array.Empty<T>(), constant data for ReadOnlySpan<byte> lives in the assembly, a non-escaping Span<T> can avoid the heap, and known sizes pre-size lists.
Collection expressions (C# 12) are a unified syntax for creating collections: [a, b, c], empty [], and spreading other collections with ... The compiler looks at the target type and emits the most efficient construction for it — arrays, spans (often on the stack), lists, immutable collections, or interfaces. I'd explain the syntax, supported targets, why there's no natural type, how the compiler chooses an implementation, spreads, and the performance and API design implications.
int[] ids = [1, 2, 3];
List<string> states = ["NSW", "VIC", "QLD"];
Span<byte> header = [0x50, 0x4B, 0x03, 0x04];
ImmutableArray<decimal> rates = [0.1m, 0.05m];
IReadOnlyList<Order> none = [];
int[] merged = [..ids, 4, ..moreIds]; // spread operator
List<Order> all = [..pending, ..paid.Where(o => o.Total > 0)];
T[]).Span<T> / ReadOnlySpan<T>.IEnumerable + have Add) such as List<T>, HashSet<T>, Dictionary isn't supported directly in C# 12/13 (key-value collection expressions are a future feature).[CollectionBuilder(typeof(Builder), "Create")] — the compiler calls a static Create(ReadOnlySpan<T>) method. ImmutableArray<T>, ImmutableList<T>, FrozenSet<T>-style types use this.IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T> (compiler picks a read-only implementation, often an array or a synthesised type), ICollection<T>, IList<T> (compiler uses List<T>).var x = [1, 2, 3]; // error CS9176: no target type
The whole point is that the same expression should compile to the best representation for its target (stack span, array, list, immutable). Choosing a default (e.g. List<T>) would bake in an allocation and make code less efficient silently. The language team left "natural type" for possible future design. Use an explicit type or a target.
Examples of what the compiler does:
int[] a = [1, 2, 3]; → allocate an array of length 3 and fill it (or use RuntimeHelpers.InitializeArray for constants).ReadOnlySpan<byte> s = [1, 2, 3]; with constants → no allocation: points to data embedded in the assembly.Span<int> s = [x, y, z]; → may use an inline array on the stack.List<int> l = [x, y, ..other]; → creates the list with the known capacity (if the spread source's count is known) and uses CollectionsMarshal.SetCount/AsSpan to fill it efficiently.ImmutableArray<int> i = [1, 2]; → calls ImmutableArray.Create(ReadOnlySpan<int>) via [CollectionBuilder], avoiding builder overhead.IEnumerable<T> e = []; → uses a cached empty array (Array.Empty<T>()).IReadOnlyList<T> r = [a, b]; → a compiler-synthesised read-only type or an array.Spreads: the compiler uses Count/Length when available to pre-size, and CopyTo/span copies for arrays and lists rather than enumerating.
params ReadOnlySpan<T> (C# 13) and span-based APIs feel natural: Process([1, 2, 3]) with no array allocation.IEnumerable<T>/IReadOnlyList<T> accept [...] from callers easily — a reason to accept interfaces.[CollectionBuilder] so they work with the syntax efficiently.IEnumerable<T>: callers may cast back to the concrete type the compiler picked — don't rely on it, and don't mutate.[..huge] is a full copy.ReadOnlySpan<T> overloads for collection expressions in some cases; C# 13/14 refined "better conversion" rules for spans.A validation library built small arrays of allowed values on every call (new[] { "NSW", "VIC", ... }.Contains(state)), allocating per request. Converting to static ReadOnlySpan<string>... (strings can't be in a constant data span), so we used static readonly FrozenSet<string> AllowedStates = ["NSW", "VIC", "QLD", "WA", "SA", "TAS", "ACT", "NT"]; via the collection expression + [CollectionBuilder] support for frozen sets (or ToFrozenSet() on older targets), and byte signatures became ReadOnlySpan<byte> Png = [0x89, 0x50, 0x4E, 0x47]; with zero allocation. Allocations in the validator dropped to zero and the code became shorter.
The interviewer checks that you understand target typing and the performance model, not just the brackets.
Green flags
var does not work.CollectionBuilder types and interfaces.Red flags
List<T>.CollectionBuilder.[CollectionBuilder(typeof(MoneyBag), nameof(MoneyBag.Create))]
public sealed class MoneyBag : IEnumerable<Money>
{
public static MoneyBag Create(ReadOnlySpan<Money> items) => new(items.ToArray());
...
}
MoneyBag bag = [Money.Aud(10), Money.Aud(5)];
The compiler builds a span (often stack-allocated) of the elements and calls Create. The builder method must be static, accessible, take a ReadOnlySpan<T> and return the collection type.
For ReadOnlySpan<T> of primitive constants, the compiler emits the data into the PE file and creates a span over it (RuntimeHelpers.CreateSpan) — no allocation, no copying, just like u8 literals. For non-constant elements, it may use [InlineArray] types on the stack.
For IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, the compiler may synthesise an internal read-only wrapper type (<>z__ReadOnlyArray<T>) to prevent mutation via casting to IList<T>. For ICollection<T>/IList<T>, it uses List<T> (mutable).
When methods have overloads taking T[], List<T>, ReadOnlySpan<T>, IEnumerable<T>, collection expression arguments prefer span overloads, then arrays, etc., based on "better conversion from expression" rules (C# 12, refined in C# 13 with params spans and C# 14 first-class spans).
[] is the cheapest way to get an empty collection: Array.Empty<T>() for arrays/interfaces, default/empty span for spans, new List<T>() for lists, ImmutableArray<T>.Empty via builder.
[k: v] has been proposed).IEnumerable<T>-compatible sources (or spans).[1,2,3] to int[], ReadOnlySpan<int>, List<int>, IEnumerable<int>.[CollectionBuilder] to a custom collection.List<int> with spreads vs AddRange.var x = [1]; and read the error.using System.Collections;
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
[CollectionBuilder(typeof(TagSetBuilder), nameof(TagSetBuilder.Create))]
public sealed class TagSet : IEnumerable<string>
{
private readonly FrozenSet<string> _tags;
internal TagSet(FrozenSet<string> tags) => _tags = tags;
public bool Contains(string tag) => _tags.Contains(tag);
public IEnumerator<string> GetEnumerator() => _tags.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public static class TagSetBuilder
{
public static TagSet Create(ReadOnlySpan<string> items) =>
new(items.ToArray().ToFrozenSet(StringComparer.OrdinalIgnoreCase));
}
public static class Program
{
static int Sum(ReadOnlySpan<int> values) // span parameter: callers can avoid heap arrays
{
int total = 0;
foreach (int v in values) total += v;
return total;
}
public static void Main()
{
int[] weekdays = [1, 2, 3, 4, 5];
List<int> weekend = [6, 7];
int[] all = [..weekdays, ..weekend]; // spread: sizes known, so pre-sized
ImmutableArray<string> states = ["NSW", "VIC", "QLD"]; // CollectionBuilder
IReadOnlyList<string> codes = [..states, "WA"]; // compiler picks a read-only type
Span<int> scratch = [0, 0, 0, 0]; // can live on the stack
int[] none = []; // Array.Empty<int>()
TagSet tags = ["vip", "Express"]; // our own builder
scratch[0] = Sum([10, 20, 30]); // no heap array for the argument
Console.WriteLine($"{all.Length} {codes.Count} {scratch[0]} {none.Length} {tags.Contains("EXPRESS")}");
// var oops = [1, 2]; // CS9176: no natural type
}
}
What happens: var items = [1, 2];.
Why it is wrong: no target type — compile error.
Do instead: declare the type.
What happens: casting an IEnumerable<T> from [...] to List<T>.
Why it is wrong: the compiler may choose arrays or synthesised types.
Do instead: treat interface-typed results as read-only.
What happens: [..hugeList] to "convert" types.
Why it is wrong: full copy.
Do instead: avoid copies or use views/spans.
What happens: new[] { ... } inside methods.
Why it is wrong: allocations per call.
Do instead: ReadOnlySpan<T> constants or static readonly collections.
CollectionBuilderWhat happens: custom types used with collection expressions via Add.
Why it is wrong: less efficient construction.
Do instead: add [CollectionBuilder].
What happens: casting IReadOnlyList<T> results to IList<T> and modifying.
Why it is wrong: may throw or break assumptions.
Do instead: create explicit List<T> when mutation is needed.
What happens: a different overload (span vs array) is chosen after converting to collection expressions. Why it is wrong: behaviour differences in overloads. Do instead: keep overload semantics consistent.
What happens: Dictionary<string,int> d = ["a": 1];.
Why it is wrong: not supported yet.
Do instead: dictionary initialisers new() { ["a"] = 1 }.
Collection expressions are more than shorter syntax: they let the compiler pick the cheapest correct representation, and they make span-based APIs pleasant to call. That changes API design advice:
| API parameter | Caller writes | Cost |
|---|---|---|
ReadOnlySpan<T> |
M([a, b]) |
often no heap allocation |
params ReadOnlySpan<T> (C# 13) |
M(a, b) or M([a, b]) |
often no heap allocation |
IEnumerable<T> |
M([a, b]) |
allocation for the backing storage |
T[] |
M([a, b]) |
array allocation |
The other senior point is consistency: one syntax for every collection makes code reviews easier and lets IDE0300-series analysers modernise old initialisers automatically. The risk is relying on unspecified concrete types behind interfaces, so I would flag casts in review.
Choose by access pattern, then mutability, then concurrency. Index access or iteration: List<T>/array. Lookup by key: Dictionary. Uniqueness or membership: HashSet. Order of processing: Queue/Stack/PriorityQueue. Sorted keys: SortedList (read-mostly) or SortedDictionary (write-heavy). Shared across threads: ConcurrentDictionary/Channel<T>, or an immutable/frozen snapshot if writes are rare. Built once and read forever: FrozenDictionary/FrozenSet/ImmutableArray. For APIs, accept the least specific type you need (IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, or ReadOnlySpan<T>) and return a specific read-only type (IReadOnlyList<T>, IReadOnlyDictionary, or an immutable type), not a mutable List<T>.
Immutable collections (System.Collections.Immutable) never change after creation; "modifying" methods such as Add return a new collection and leave the old one untouched, so they are safe to share across threads without locks. ImmutableArray<T> is a struct around a plain array: reads are as fast as an array, but every Add copies the whole thing (O(n)). ImmutableList<T> and ImmutableDictionary<TKey,TValue> are balanced trees that share structure: an Add is O(log n) and reuses most of the old nodes, but reads are slower. Use builders (ToBuilder() ... ToImmutable()) for many changes. A read-only view like ReadOnlyCollection<T> or IReadOnlyList<T> only stops you changing it; the underlying list can still change. FrozenDictionary/FrozenSet (.NET 8+) are immutable too but optimised purely for fast reads with no "modified copy" operations.
A list pattern (C# 11) matches a sequence by its elements: [] is empty, [var x] has exactly one element, [1, _, 3] has three with given first and last, [var first, ..] has at least one, and [.., var last] captures the last. Each element position takes any pattern (constant, relational, type, var, _). .. is a slice pattern matching zero or more elements, and at most one is allowed; .. var rest captures the slice. A type supports list patterns if it is countable (Length or Count) and indexable (an int or Index indexer); capturing a slice also needs a Range indexer or a Slice method. Arrays, List<T>, strings, Span<T> and ReadOnlySpan<T> work; IEnumerable<T> does not. Slices of spans are free, while slices of arrays, lists and strings allocate copies.
How to tell it (STAR)
**Situation:** our [API gateway] logged telemetry tags on every request and allocation profiles showed those tag arrays near the top. **Task:** I wanted to cut allocations without changing every call site by hand. **Action:** I changed the helper to accept a params ReadOnlySpan, which works with collection expressions, and let the IDE fix-ups update callers. **Result:** the tag allocations disappeared from the profile, Gen 0 collections dropped by [25%], and the call sites became shorter.
Last updated 30 Oct 2026.