Newsletter Archive
Browse through our collection of past newsletters. Each edition is packed with C# and .NET insights.
Page 1 of 17 (49 editions)
Quick: how do you create a List<int> with three items? new List<int> { 1, 2, 3 }? Or maybe new List<int>([1, 2, 3])? An array? new int[] { 1, 2, 3 }. A span? stackalloc int[] { 1, 2, 3 }. An immutable array? ImmutableArray.Create(1, 2, 3).
Every collection type had its own ceremony. C# 12 introduces collection expressions — one syntax that targets them all:
// All of these use the same [1, 2, 3] syntaxint[] array = [1, 2, 3];List<int> list = [1, 2, 3];Span<int> span = [1, 2, 3];ReadOnlySpan<int> roSpan = [1, 2, 3];ImmutableArray<int> immutable = [1, 2, 3];HashSet<int> set = [1, 2, 3];The compiler figures out what to emit based on the target type. For Span<int>, it stack-allocates. For List<int>, it creates a list with the right capacity. For ImmutableArray<int>, it uses the builder. You don’t need to know the optimal construction API for each type. The compiler does.
The Spread Operator
Need to combine collections? The .. spread operator inlines one collection into another:
int[] first = [1, 2, 3];int[] second = [4, 5, 6];
int[] combined = [..first, ..second]; // [1, 2, 3, 4, 5, 6]int[] withExtra = [0, ..first, ..second, 7, 8]; // [0, 1, 2, 3, 4, 5, 6, 7, 8]This replaces the Concat + ToArray pattern, and the compiler can optimize it into a single allocation with known size.
Practical Example: Building Middleware Pipelines
public static IEnumerable<string> GetCorsOrigins(bool isDevelopment){ string[] production = ["https://app.example.com", "https://admin.example.com"]; string[] development = ["http://localhost:3000", "http://localhost:5173"];
return isDevelopment ? [..production, ..development] : production;}No Enumerable.Concat(). No .ToArray(). No LINQ allocation chain. Just a clean expression.
Empty Collections
The empty collection literal [] is also the new best way to return or initialize an empty collection:
// Before: Array.Empty<string>(), new List<string>(), Enumerable.Empty<string>()// After:string[] empty = [];List<string> emptyList = [];ImmutableArray<string> emptyImmutable = [];The compiler emits the most efficient empty representation for each target type (Array.Empty<T>() for arrays, a cached empty instance for immutable arrays, etc.).
Key Takeaway
[1, 2, 3] works for arrays, lists, spans, immutable collections, and sets. The .. spread operator combines them without LINQ overhead. One syntax, all collection types, zero ceremony. If you’re still writing new List<int> { ... }, you’re writing more characters than you need to.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
You’ve got five API calls firing in parallel, and you want to process each result as soon as it lands instead of waiting for the slowest one to finish. Before .NET 9, this meant a gnarly while loop with Task.WhenAny, removing completed tasks from a list, and praying you didn’t mess up the bookkeeping. Task.WhenEach makes that entire pattern a one-liner.
Here’s the old way (functional, but ugly):
// The "before": nobody enjoyed writing thisvar tasks = new List<Task<string>>{ FetchAsync("https://api.example.com/users"), FetchAsync("https://api.example.com/orders"), FetchAsync("https://api.example.com/products"),};
while (tasks.Count > 0){ var completed = await Task.WhenAny(tasks); tasks.Remove(completed); Console.WriteLine(await completed);}And here’s the .NET 9 way:
using System.Net.Http;
var client = new HttpClient();
var tasks = new[]{ FetchAsync("https://jsonplaceholder.typicode.com/posts/1"), FetchAsync("https://jsonplaceholder.typicode.com/posts/2"), FetchAsync("https://jsonplaceholder.typicode.com/posts/3"),};
await foreach (var completed in Task.WhenEach(tasks)){ var result = await completed; // already done, returns instantly Console.WriteLine($"Got: {result[..Math.Min(80, result.Length)]}...");}
async Task<string> FetchAsync(string url){ await Task.Delay(Random.Shared.Next(100, 1000)); // simulate variable latency return await client.GetStringAsync(url);}Task.WhenEach returns an IAsyncEnumerable<Task<T>> that yields each task in the order it completes, not the order it was started. You just await foreach over it and handle results as they stream in. The code reads like English, and there’s zero manual bookkeeping.
This pattern is perfect for fan-out scenarios: calling multiple microservices, scraping multiple URLs, or running parallel database queries. Anywhere the response times vary and you want to start processing ASAP, this delivers. Combine it with a CancellationToken and you can even bail out early once you’ve got what you need.
No more WhenAny loops. No more mutable lists. Just await foreach and vibes.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
For two decades, the pattern has been the same: declare a private readonly object _lock = new(), wrap your critical section in lock(_lock), and hope nobody accidentally locks on this, a string literal, or some other shared reference. It works, but it’s a pattern held together by convention, not by the type system.
.NET 9 introduces System.Threading.Lock, a purpose-built type that the compiler recognizes and optimizes. No more locking on arbitrary objects.
The Old Way
public class ConnectionPool{ private readonly object _lock = new(); private readonly List<Connection> _connections = [];
public Connection Acquire() { lock (_lock) { var conn = _connections.FirstOrDefault(c => !c.InUse); if (conn is not null) conn.InUse = true; return conn ?? CreateNew(); } }}The compiler turns that lock into Monitor.Enter / Monitor.Exit. It works, but there’s no type safety — you could accidentally pass _lock to something that locks on it elsewhere, or some junior dev writes lock(this) in a code review at 5 PM.
The New Way
using System.Threading;
public class ConnectionPool{ private readonly Lock _lock = new(); private readonly List<Connection> _connections = [];
public Connection Acquire() { lock (_lock) { var conn = _connections.FirstOrDefault(c => !c.InUse); if (conn is not null) conn.InUse = true; return conn ?? CreateNew(); } }}Looks almost identical, right? The difference is under the hood. When the compiler sees lock on a System.Threading.Lock, it emits a call to Lock.EnterScope() which returns a ref struct that releases the lock on Dispose. This is more efficient than Monitor.Enter/Exit because the JIT can optimize the scope-based pattern better.
Scoped Locking
You can also use the scope explicitly when you need more control:
public void TransferAll(ConnectionPool other){ using (_lock.EnterScope()) { foreach (var conn in _connections) { other.Add(conn); } _connections.Clear(); }}The Lock.Scope is a ref struct, so it can’t escape the method or get boxed. The lock is guaranteed to release when the scope ends, even if an exception fires.
Why Bother?
- Type safety. You can’t accidentally lock on a string, a type, or
this. The type system prevents misuse. - Performance.
EnterScope()avoids the overhead ofMonitor’s thread-affinity tracking in the uncontended case. - Intent. Seeing
Lockin a field declaration screams “synchronization primitive.” Seeingobjectscreams nothing.
Key Takeaway
Replace private readonly object _lock = new() with private readonly Lock _lock = new(). Same lock keyword, same syntax, better codegen, and the type system finally prevents the classic locking mistakes. One find-and-replace, zero behavior changes.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.