AlgoMaster Logo

Collection Expressions

Medium Priority19 min readUpdated June 6, 2026

Collection expressions, added in C# 12, give you a single literal shape, [a, b, c], that works for arrays, lists, spans, immutable arrays, and any collection type that opts in. This lesson zooms in on the language feature itself: how target typing drives the construction, what the spread operator compiles into, what conversions the compiler is willing to make, and where the feature stops. The focus is on the rules the compiler follows and how to migrate older code safely.

The Literal Has No Type

A collection expression is a syntactic shape, not a type. Writing [1, 2, 3] on its own gives the compiler an ordered list of element expressions and nothing else. The same five characters have to become an int[], a List<int>, a ReadOnlySpan<int>, or something else depending on the surrounding context.

That sounds abstract until you try to use it without a context:

The compiler reports CS9176 because var infers from the right side, and the right side is shapeless. Tuples have a default type ((int, int, int) becomes ValueTuple<int, int, int>), lambdas have a synthesized delegate, but a collection expression has nothing to fall back on. The shape needs a target.

The target comes from one of four places: the variable's declared type, a method parameter, a return type, or an explicit cast. Each one feeds the same target-typing pipeline.

In every line the literal looks identical. What changes is where the type information enters the picture. The cast form: var works there because var is reading the type of the cast result, not of the bare literal. The cast wraps the literal in something that has a type, which var can then use.

How Target Typing Picks a Concrete Type

Once the compiler has a target type, it follows a small decision tree to choose how to construct the value. The decision is driven by the target type's shape, not by the elements inside the literal.

The diagram is what the compiler walks at the source-code level. It's not a runtime cost, it happens once during compilation, and the emitted IL bakes in whichever path matched. The branch matters because each path uses a different construction strategy and has a different cost profile.

A small program lets you see each branch fire:

Output (representative on .NET 8):

The interface case lands on a compiler-synthesized read-only wrapper. The name (<>z__ReadOnlyArray here) is an implementation detail. You should not rely on it. Your contract is the interface; the compiler picks whatever satisfies the interface efficiently.

When the target is an interface, the compiler may pick a non-List<T> backing type to skip the list's growth overhead. If you then cast back to List<T> to mutate the result, the cast will fail at runtime with InvalidCastException. Either declare the variable as List<T> from the start, or treat the interface as read-only.

What the Spread Operator Actually Does

The spread operator .. is a second piece of syntax that only exists inside a collection expression. Writing ..source says "take every element of source in order and place them here as if they were typed inline." source must be enumerable. The spread can sit anywhere in the literal, mixed with normal elements.

The compiler does not just expand this into a textual rewrite. What it emits depends on what it knows about the source:

Source kindWhat the compiler emits
T[] (countable)Compute total size, one allocation, Array.Copy for each spread
List<T> (countable)Compute total size, one allocation, CopyTo for each spread
ReadOnlySpan<T> (countable)Same as array path
ICollection<T> (countable via Count)Compute total size, allocate once, copy via foreach
IEnumerable<T> (unknown count)Allocate a growable buffer, enumerate and append, trim or freeze at end

The first three cases are the fast path. The compiler knows the final size up front, so it allocates exactly the right amount of memory and bulk-copies. For an int[] with three elements, the entire spread becomes one allocation and one memcpy under the hood.

The last case is the slow path. When the source is an IEnumerable<T> without a known count (a LINQ chain, a yield return method, a custom enumerator), the compiler can't size the result up front. It uses a buffer that grows as needed, then trims to the final size. This is the same shape as ToList() internally.

A spread from IEnumerable<T> walks the source. Some LINQ chains, especially those that don't implement ICollection<T>, may also be iterated twice in some compiler versions when both a count probe and the actual enumeration happen. If you're spreading the same Where(...) chain repeatedly, materialize it once with .ToArray() and spread the array.

Here's an example that shows the cost difference visibly:

Output (representative, your numbers will vary):

The array spread is a single bulk copy per iteration. The lazy spread enumerates the chain element by element and writes into a growable buffer per iteration. The roughly 7x gap on this machine matches the difference between memcpy and an enumeration loop with virtual calls.

The element type of the spread doesn't have to match the target's element type exactly. Any implicit conversion that would work on a single element also works during spread:

The int elements are implicitly converted to decimal during the spread, because int to decimal is an implicit conversion in C#. If the conversion isn't implicit (say string to int), the spread fails to compile with CS0029 or CS0266, the same errors you'd get for a single-element assignment.

The Empty Literal

[] is the universal empty collection expression. It works for every target type that the non-empty form works for, and the compiler picks the best empty-construction strategy per target.

The interesting line is the first one. For int[] x = [], the compiler doesn't emit new int[0]. It emits Array.Empty<int>(), which returns a cached, shared zero-length array. Every int[] x = [] in your program points at the same instance. Pre-C# 12, you got this optimization only if you remembered to write Array.Empty<T>() yourself. The new syntax bakes it in.

For List<T> the compiler still has to allocate, because lists are mutable and each caller wants their own instance to add to. The same applies to ImmutableArray<T>, which uses its static Empty field. For interface targets the wrapper around a cached empty array is used. Each target uses the cheapest construction available.

This matters in hot paths that return empty collections:

Output (representative):

The first loop allocates a hundred million tiny zero-length arrays, all of which become garbage. The second loop reuses the same cached singleton. The garbage-collection cost on the first loop is what the second loop avoids.

CollectionBuilderAttribute and Opting In

Most user-defined collection types can wire themselves into collection expressions with CollectionBuilderAttribute. The pattern is:

  1. Mark the collection type with [CollectionBuilder(typeof(BuilderType), "MethodName")].
  2. Give BuilderType a static method MethodName(ReadOnlySpan<TElement> items) that returns the collection.

The compiler takes care of packing the literal's elements into the span and calling the builder. The user of the collection writes [a, b, c] and the compiler does the rest.

A small wishlist collection that opts in:

The literal [4001, 4002, 4003] becomes a ReadOnlySpan<int> filled with those three values, and Wishlist.Create runs with that span. The author of Wishlist controls how the elements turn into an instance. The caller never sees the builder method, only the literal.

The builder method's signature is fixed by the compiler:

AspectRule
Return typeMust be the collection type (or a type assignable to it)
ParameterMust be ReadOnlySpan<TElement>
ModifiersMust be static
GenericsThe element type must align with the collection's element type

This is the BCL pattern for ImmutableArray<T>, FrozenSet<T>, and any read-only structural collection that wants the literal-style construction. Internally, the BCL builders often inspect the span and short-circuit on empty input to return a shared empty instance, the same optimization arrays receive automatically.

There's also [InlineArray(N)], a related compiler feature. An inline array is a struct with a single field that the runtime treats as a contiguous N-element buffer. Roslyn uses synthesized inline arrays under the hood to build small ReadOnlySpan<T> literals without a heap allocation. You almost never write [InlineArray] yourself, but knowing it exists explains why span literals don't allocate.

A FourPrices value contains four decimal slots laid out contiguously, accessed via the implicit indexer (fp[0], fp[1], etc.). When the compiler builds a small ReadOnlySpan<decimal> from a literal, it can stack-allocate a synthesized struct shaped like this and hand the span over a span over that buffer, dodging the heap entirely. This is the mechanism behind the span-target fast path.

What Collection Expressions Don't Support

Collection expressions in C# 12 are tailored for value-per-element collections. There are real cases where the syntax doesn't apply, and forcing them into the literal form will fail to compile.

The biggest gap is key-value collections. There is no [k: v, k: v] syntax. Dictionaries continue to use the older initializer forms:

The C# language design team has been discussing a key-value literal for a future version. As of C# 12, it isn't there. The same restriction applies to other paired-input shapes: KeyedCollection<TKey, TItem>, multi-arg Add methods on custom collections, and any builder that needs more than one value per element.

Other places the literal can't appear:

Each of these has a different underlying reason. Attributes need constants baked into metadata; the literal isn't a constant. nameof needs a symbol, not a value. A generic constraint T : IEnumerable<int> doesn't tell the compiler whether to construct a List<int>, an int[], or something else, so it gives up. IAsyncEnumerable<T> has no CollectionBuilder because async streams are produced by await foreach and yield, not by literal construction.

There's also a more subtle gap: collection types that need configuration at construction. A HashSet<string> with StringComparer.OrdinalIgnoreCase can't be expressed as HashSet<string> set = ["A", "a"]; and get the custom comparer. The literal has no place to specify it. You fall back to the constructor form:

The literal is for "build a normal instance of this type with these elements." If you need to plumb in extra configuration, the literal isn't the appropriate choice.

Migrating Older Code

Existing C# code is full of pre-C# 12 collection construction. The new syntax replaces several patterns at once. The migration is usually mechanical, but a few cases need care.

Old shapeNew shapeNotes
new int[] { 1, 2, 3 }[1, 2, 3]Direct one-for-one swap
new[] { 1, 2, 3 }[1, 2, 3]Requires explicit variable type
new int[0][]Bigger win: avoids per-call allocation
Array.Empty<int>()[]Same behavior, shorter source
new List<int> { 1, 2, 3 }[1, 2, 3]Compiler also picks better capacity
new List<int>()[]Use as List<int> x = []
ImmutableArray.Create(1, 2, 3)[1, 2, 3]Routes through CollectionBuilder
ImmutableArray<int>.Empty[]Same singleton, shorter source
Enumerable.Empty<int>()[]Use as IEnumerable<int> x = []
arr1.Concat(arr2).ToArray()[..arr1, ..arr2]Single allocation, bulk copy

A few transformations need attention. The new[] { ... } row is worth a closer look. The implicitly typed array uses element-type inference on the right side, so var x = new[] { 1, 2, 3 } infers int[]. The collection-expression form has no inference; the variable's type has to be explicit, so the migration is int[] x = [1, 2, 3], not var x = [1, 2, 3].

The Concat(...).ToArray() row is a behavioral upgrade. The old form constructs a deferred IEnumerable<int> from Concat, then materializes it with ToArray, which walks the chain and grows a buffer. The new form skips the intermediate enumerable entirely. For two arrays of known length, it computes the final size once, allocates once, and uses Array.Copy to fill each region. The behavior is identical, the allocations are fewer, and the source is shorter.

Where to be careful:

The third point is the subtle one. If anything in your code uses ReferenceEquals on returned empty arrays as a sentinel ("is this the empty I returned earlier?"), the new form changes that answer. The behavior is usually an improvement (you wanted the shared singleton anyway), but the migration could expose latent assumptions. Read uses of ReferenceEquals, == on arrays, and identity-based caching before changing return paths.

A migration that changes a method from new int[0] to [] doesn't change the contract for callers, but it does change identity. Anything storing the returned empty array as a hash-map key or ConcurrentDictionary key will start colliding with every other empty result. Audit identity uses before flipping.

The opposite direction also exists: places where the old syntax is still required, despite the new one being available. The three cases that come up most often:

  1. Custom collections without `CollectionBuilder` (like the Tracker above). The new syntax can't reach types that haven't opted in.
  2. Dictionary literals until a future C# version adds them.
  3. Collections constructed with non-default configuration (custom comparer, custom capacity, special constructor arguments).

For everything else, the migration is mechanical and the new code is shorter and faster.

Migration Worked Example

To make the migration concrete, here's an order-processing snippet in pre-C# 12 style, then rewritten using collection expressions throughout.

Before:

After:

Four methods, four old shapes (new T[] { ... }, Concat().ToList(), ImmutableArray.CreateRange, Array.Empty<T>()) collapsed into the same [...] form. The behavior is equivalent. The performance is the same or better in each case. The source is uniformly shorter and the reader sees a consistent idiom instead of four different ones.

The migration is not a single regex-rewrite, because some places still need the old form (Tracker-style custom collections, dictionaries, custom comparers). The typical workflow is: read each call site, decide whether the new syntax fits, and only convert the ones that do. A blind sweep risks breaking the edge cases above.

Quiz

Collection Expressions Quiz

10 quizzes