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.
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.
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.
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.
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 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.
[1, 2, 3]int[] · List<int> · Span<int> · IEnumerable<int> · HashSet<int>
int[] numbers = [1, 2, 3];
List<string> names = ["Alice", "Bob"];
Dictionary<string, int> scores = new() { ["Alice"] = 90 }; // dictionaries use their own syntax still
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];
[...] syntax adapts to each target — you write one form, and the compiler generates the appropriate construction code for whatever you assigned it to.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.
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.
new List<T>() plus a sequence of Add calls (with capacity pre-sized when it can compute the final count up front, avoiding repeated internal resizing).stackalloc for value types) rather than on the managed heap at all — a genuine performance win over the old array-then-implicit-conversion approach.[CollectionBuilder]-attributed factory method (the mechanism ImmutableArray<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.
[] needs a target type — it can't stand entirely on its ownJust 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.
.. here is unrelated to the range operator .. used in indexingC# 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.
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.
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
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.
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.
Concat, Prepend, Append, or manual "add these existing items too" loops.new Dictionary<...>() initializer syntax for dictionaries, since collection expressions don't (currently) cover key/value collections.[e1, e2, ...], are target-typed — the same bracket syntax produces an array, List<T>, Span<T>, or other collection type depending on context... inlines the contents of another collection at that position, replacing manual Concat/loop-and-add code.var.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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.