C# Collection Expressions: The [ ] Syntax and Spread ..
One bracket syntax, and the compiler figures out whether you meant an array, a List, a Span, or something else entirely.
Quick: what's the syntax for creating a new array with three numbers in it? An empty List<string>? A Span<int> with the same three numbers? If you've written C# for a while, you know the honest answer is "three different syntaxes, and you'd better remember which is which" — new[] { 1, 2, 3 }, new List<string>(), stackalloc int[] { 1, 2, 3 }. Each collection type historically brought its own initializer dialect.
C# 12 introduces a single, unified syntax that works across nearly every collection type in .NET: square brackets. [1, 2, 3] means "a collection containing 1, 2, and 3" — and the compiler figures out from context whether that should become an array, a List<T>, a Span<T>, or something else, without you writing a different incantation for each one.
In this lesson, you'll learn collection expression syntax ([1, 2, 3]), the spread operator (..) for combining collections, how the same syntax adapts across arrays, List<T>, Span<T>, and other collection types, and why this replaces a decade of increasingly inconsistent initializer syntax.
What Is It?
The Simple Explanation
A collection expression is a bracket-enclosed list of values — [1, 2, 3] — that creates a collection. What kind of collection it becomes depends entirely on what you're assigning it to: assign it to an int[] and you get an array; assign it to a List<int> and you get a list. Same syntax, different result, chosen by context — exactly the way 0 can be an int, a double, or a byte depending on where it's used.
The Technical Definition
A collection expression (C# 12) is a target-typed expression of the form [e1, e2, ..., en] that the compiler converts into whatever collection type the expression is being assigned or passed to — an array, List<T>, Span<T>, ReadOnlySpan<T>, any type implementing the standard collection interfaces (like IEnumerable<T>), or any type exposing a compatible Create factory pattern recognized via the [CollectionBuilder] attribute. The spread element .. inlines the contents of another collection directly into a collection expression, at the position where it appears.
Why Does It Exist?
The Problem
Every collection type in .NET grew its own initializer syntax over the years, and none of them match:
int[] numbers = new int[] { 1, 2, 3 };
int[] shortForm = { 1, 2, 3 }; // only legal at declaration
List<int> list = new List<int> { 1, 2, 3 };
List<int> listInferred = new() { 1, 2, 3 }; // target-typed new, C# 9
Span<int> span = stackalloc int[] { 1, 2, 3 };
int[] combined = numbers.Concat(new[] { 4, 5 }).ToArray(); // "add two arrays together"
Beyond the inconsistency, that last line highlights a real pain point: combining collections — "take everything from this array, then everything from that one" — required reaching for LINQ's Concat and ToArray, turning a conceptually simple operation into a multi-step chain.
The Need
Developers needed one consistent syntax for "here are the values in this collection" that worked the same way regardless of the target type, plus a direct, readable way to combine or inline other collections without a detour through LINQ.
The Solution
The same code, with collection expressions:
int[] numbers = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];
int[] combined = [.. numbers, 4, 5]; // spread numbers' contents in, then append 4 and 5
One bracket syntax across every target, and the spread operator makes combining collections a direct, readable part of the literal itself.
Big Picture
[1, 2, 3]↓ compiler looks at the target type ↓
int[] · List<int> · Span<int> · IEnumerable<int> · HashSet<int>
How It Works
int[] numbers = [1, 2, 3];
List<string> names = ["Alice", "Bob"];
Dictionary<string, int> scores = new() { ["Alice"] = 90 }; // dictionaries use their own syntax still
- The bracket syntax works for arrays, lists, spans, and any collection-like type that implements the standard interfaces or opts in via a collection-builder pattern.
- Dictionaries aren't (yet) part of collection expressions — they keep their existing key/value initializer syntax.
List<int> empty = []; // equivalent to new List<int>()
int[] emptyArray = [];
int[] first = [1, 2, 3];
int[] second = [4, 5];
int[] combined = [.. first, .. second]; // [1, 2, 3, 4, 5]
int[] withExtra = [0, .. first, 99]; // [0, 1, 2, 3, 99]
..inlines every element of another collection right where it appears — you can spread multiple collections, and mix spread elements with individual values, in any order.
ReadOnlySpan<int> span = [1, 2, 3]; // no heap allocation for this one
HashSet<string> tags = ["c#", "dotnet"];
IEnumerable<int> sequence = [1, 2, 3];
- The same
[...]syntax adapts to each target — you write one form, and the compiler generates the appropriate construction code for whatever you assigned it to.
Simple Example
int[] weekdays = [1, 2, 3, 4, 5];
int[] weekend = [6, 7];
int[] fullWeek = [.. weekdays, .. weekend];
Console.WriteLine(string.Join(", ", fullWeek)); // 1, 2, 3, 4, 5, 6, 7
List<string> baseTags = ["c#", "dotnet"];
List<string> extendedTags = [.. baseTags, "records", "patterns"];
Console.WriteLine(string.Join(", ", extendedTags)); // c#, dotnet, records, patterns
Code → Meaning → Result: [.. weekdays, .. weekend] reads almost like plain English — "everything from weekdays, then everything from weekend" — and produces a brand-new array holding all seven values, without a single call to Concat or ToArray.
Real-World Example
Building a product catalog page that combines a curated list of "featured" products with the regular catalog, while excluding anything currently out of stock:
public record Product(string Name, decimal Price, bool InStock);
List<Product> GetFeaturedProducts() =>
[
new Product("Wireless Mouse", 24.99m, true),
new Product("Mechanical Keyboard", 89.99m, true)
];
List<Product> GetCatalogProducts(List<Product> inventory)
{
List<Product> featured = GetFeaturedProducts();
List<Product> inStockInventory = inventory.Where(p => p.InStock).ToList();
// Featured items always appear first, followed by the rest of the in-stock catalog
return [.. featured, .. inStockInventory];
}
// ─── Usage: adding a promotional item on top of an existing list, without mutating it ───
List<Product> catalog = GetCatalogProducts(inventory);
List<Product> withPromo = [new Product("Flash Sale Item", 9.99m, true), .. catalog];
Each line reads as a direct description of what's being built — "featured, then in-stock inventory," or "a promo item, then the whole existing catalog" — with the spread operator doing the work that would otherwise need Concat, Prepend, or manual loop-and-add code.
Under the Hood
- For an array of a known, fixed length, the compiler can generate a direct array allocation with the elements assigned in place — no intermediate list needed.
- For a List<T>, it generally compiles to a
new List<T>()plus a sequence ofAddcalls (with capacity pre-sized when it can compute the final count up front, avoiding repeated internal resizing). - For a Span<T> or ReadOnlySpan<T> over a small, fixed set of values, the compiler can allocate the storage inline (similar to
stackallocfor value types) rather than on the managed heap at all — a genuine performance win over the old array-then-implicit-conversion approach. - For a type with a
[CollectionBuilder]-attributed factory method (the mechanismImmutableArray<T>and similar types use), the compiler calls that factory method directly.
The upshot: collection expressions aren't just shorter to write — the compiler often generates more efficient code than the equivalent hand-written initializer, because it can pick the best strategy for each specific target type rather than you having to know and choose it yourself.
Common Confusion
1. [] needs a target type — it can't stand entirely on its own
Just like new() (target-typed new, C# 9), a collection expression needs to know what type it's becoming. var x = [1, 2, 3]; doesn't compile — the compiler has no target to infer from, since var alone gives it nothing to pick from. You need an explicit target: a declared type (int[] x = [1, 2, 3];), a parameter type, or a return type.
2. The spread operator .. here is unrelated to the range operator .. used in indexing
C# also uses .. for range expressions like array[1..3] (a slice from index 1 up to, but not including, index 3). Inside a collection expression, .. means something different — "spread this entire collection's elements in here" — not a range. Context (inside [...] as a standalone prefix before a collection, versus inside indexing brackets) disambiguates the two uses.
3. Dictionaries don't (yet) use collection-expression bracket syntax
You still initialize a Dictionary<TKey, TValue> with its existing collection-initializer or index-initializer syntax (new() { ["key"] = value }). Collection expressions currently target sequence-shaped collections (things you enumerate one element at a time), not key/value stores.
Common Mistakes
Mistake 1 — Trying to use a collection expression without a clear target type
var numbers = [1, 2, 3]; // CS9176: there is no target type for this collection expression
Give the compiler a type to target:
int[] numbers = [1, 2, 3]; // fine
Mistake 2 — Forgetting that spreading into an array still creates a brand-new array
Assuming [.. original, extraItem] somehow appends in place, mutating original:
int[] original = [1, 2, 3];
int[] extended = [.. original, 4];
original[0] = 99;
Console.WriteLine(extended[0]); // still 1 — extended is a completely separate array
Understand that a collection expression, spread or not, always constructs a new collection — it copies elements in, it never reaches back into the source collection to modify it.
Mistake 3 — Spreading a very large collection repeatedly in a hot loop
Each spread still has to enumerate and copy the source collection's elements. Spreading a large collection inside a loop that runs many times can add up to real, avoidable allocation and copying cost — the same caution that applies to any repeated collection-building code, collection expressions included.
When Should I Use It?
- Use collection expressions by default for array, list, and span initialization in new C# 12+ code — they're shorter, consistent across target types, and often compile to more efficient code than the older syntax.
- Use the spread operator whenever you'd otherwise reach for
Concat,Prepend,Append, or manual "add these existing items too" loops. - Keep using explicit
new Dictionary<...>()initializer syntax for dictionaries, since collection expressions don't (currently) cover key/value collections.
Mental Model
.. (inside brackets) = "and pour in everything from this other collection, right here."
Remember: a collection expression always builds a new collection — spreading never mutates the source, and the syntax needs a known target type to compile.
Key Takeaway
- Collection expressions (C# 12),
[e1, e2, ...], are target-typed — the same bracket syntax produces an array,List<T>,Span<T>, or other collection type depending on context. - The spread operator
..inlines the contents of another collection at that position, replacing manualConcat/loop-and-add code. - A collection expression needs a target type to compile — it can't be used with a bare
var. - Under the hood, the compiler picks the most efficient construction strategy per target type — often outperforming the equivalent hand-written initializer.
- Every collection expression, spread included, builds a genuinely new collection — it never mutates a spread-in source collection.
Check Your Understanding
You've seen how one bracket syntax adapts across collection types, and how the spread operator inlines other collections. Let's check your understanding.
1. Why does var numbers = [1, 2, 3]; fail to compile?
Show answer
Correct: B
Why B is correct: Collection expressions are target-typed — the same way new() needs a known type to construct, [1, 2, 3] needs to know whether it should become an array, a list, a span, or something else. var provides no such information, so the compiler has nothing to convert the expression into.
Why A is incorrect: Collection expressions work with any element type, numbers included — the failure has nothing to do with the element type.
Why C is incorrect: The spread operator is entirely optional; plain literal elements are completely valid on their own.
Why D is incorrect: There's no minimum element count — even an empty [] is valid, as long as a target type is known.
Reinforcement: Always pair a collection expression with an explicit target type — a declared variable type, a parameter type, or a return type.
2. Given int[] a = [1, 2]; int[] b = [.. a, 3]; a[0] = 99;, what is b[0] after this code runs?
Show answer
Correct: B
Why B is correct: A collection expression always constructs a genuinely new collection. Spreading a into b's literal copies the values at that point in time — b is a completely independent array afterward, so mutating a later has no effect on b.
Why A is incorrect: Collection expressions don't create aliases or shared references — each one builds its own new collection.
Why C is incorrect: Nothing about this code involves a null reference — both arrays are fully constructed and valid.
Why D is incorrect: Spreading preserves the original order of elements; it doesn't shuffle them.
Reinforcement: Spreading copies values in at the moment the collection expression runs — it never creates a live link back to the source collection.
3. Which of these best explains why collection expressions can be more efficient than hand-written initializer syntax, not just shorter?
Show answer
Correct: B
Why B is correct: Because the compiler knows the exact target type at compile time, it can generate the most appropriate construction code for that specific type — direct allocation for arrays, capacity-aware construction for lists, inline (non-heap) storage for spans — often better than a generic hand-rolled approach would achieve.
Why A is incorrect: The efficiency gain is about choosing the right strategy per target type, not a blanket "always less memory" guarantee.
Why C is incorrect: Collection expressions have nothing to do with threading — this is purely about how the collection is constructed on the current thread.
Why D is incorrect: Type checking still fully applies — the elements must be compatible with the target collection's element type.
Reinforcement: The performance benefit comes from target-type-aware code generation at compile time, not from skipping any safety checks.
You can now build and combine collections with one consistent, readable syntax across arrays, lists, spans, and more.