Newsletter Archive
Browse through our collection of past newsletters. Each edition is packed with C# and .NET insights.
Page 1 of 10 (28 editions)
Extension Members: C# 14 Breaks the ‘this’ Barrier
For over 15 years, extension methods have been one of C#‘s most beloved features. But they’ve always had a frustrating limitation in that you can only add methods. Want an extension property? An extension operator? A static helper that feels like it belongs on the type? Sorry, best we can do is another this parameter.
C# 14 changes that. The new extension block syntax lets you add properties, operators, and static members to types you don’t own.
The Old Way
We’ve all written utility classes like this:
public static class StringExtensions{ public static bool IsNullOrEmpty(this string? s) => string.IsNullOrEmpty(s);
public static string Truncate(this string s, int maxLength) => s.Length <= maxLength ? s : s[..maxLength];}It works, but IsNullOrEmpty should be a property. There’s no argument, no verb, it’s clearly a state check. And if you wanted a static helper like string.Join but custom? Impossible through extensions.
The New Way
C# 14 introduces the extension block. You declare it inside a static class, specify the receiver type, and then define members as if you were writing them inside the type itself:
public static class StringExtensions{ extension(string? s) { // Extension property — no more parentheses for simple checks public bool IsNullOrEmpty => string.IsNullOrEmpty(s);
// Extension method — works just like before, cleaner grouping public string Truncate(int maxLength) => s is null || s.Length <= maxLength ? s! : s[..maxLength]; }}Now you call IsNullOrEmpty the way it always should have been:
string? name = GetName();
if (name.IsNullOrEmpty) Console.WriteLine("No name provided");
string preview = name.Truncate(50);No parentheses, no “is this a method or a property?” confusion. It just reads like a member.
Static Extension Members
Want to add a factory method or constant that lives on the type itself? Use the parameterless receiver syntax:
public static class GuidExtensions{ extension(Guid) { public static Guid CreateFrom(string input) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return new Guid(hash.AsSpan(0, 16)); } }}Now it’s called like a real static member:
No GuidHelper.CreateFrom(...). No hunting for which utility class has the method. It’s right where you’d expect it.
Generics Work Too
Extension blocks support generic type parameters and constraints:
public static class EnumerableExtensions{ extension<T>(IEnumerable<T> source) { public bool IsEmpty => !source.Any();
public IEnumerable<T> WhereGreaterThan(T value) where T : IComparable<T> => source.Where(x => x.CompareTo(value) > 0); }}var numbers = new List<int> { 1, 2, 3, 4, 5 };
if (!numbers.IsEmpty){ var big = numbers.WhereGreaterThan(3); // [4, 5]}What You Can’t Do (Yet)
- No backing fields - extension properties are computed only, no stored state.
- No constructors or events - you’re extending the surface, not the internals.
- Old-style extensions still work - the classic
thisparameter syntax isn’t going anywhere. You can mix both in the same project.
The Bottom Line
Extension members take the most-used workaround in C# and make them real. Properties feel like properties. Static helpers live on the type. Your code reads more naturally, and your consumers don’t need to know the difference.
If you maintain a library or a shared “Extensions” folder, C# 14 just made it a lot more expressive.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
Ditch the Double Lookup: CollectionsMarshal.GetValueRefOrAddDefault
Here’s a pattern every C# developer has written a hundred times for counting occurrences, aggregating values, or building up a cache:
var wordCounts = new Dictionary<string, int>();
foreach (var word in words){ if (wordCounts.TryGetValue(word, out var count)) wordCounts[word] = count + 1; else wordCounts[word] = 1;}It works. It’s clear. But it hashes the key twice on every iteration, once for TryGetValue and once for the indexer set. On a hot path with millions of entries, that adds up fast.
Since .NET 6, System.Runtime.InteropServices.CollectionsMarshal gives you a way to do this in a single lookup. The GetValueRefOrAddDefault method returns a ref directly into the dictionary’s internal storage:
using System.Runtime.InteropServices;
var wordCounts = new Dictionary<string, int>();
foreach (var word in words){ ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault( wordCounts, word, out bool exists);
// 'count' is a ref to the slot — mutate it in place count++;}That’s it. One hash, one probe, one update. If the key already existed, exists is true and count points at the current value. If it didn’t, the runtime inserts a default entry (0 for int) and count points at that new slot. Either way, count++ does the right thing.
This works beautifully for any “upsert” operation. Building a grouped lookup? Same idea:
var grouped = new Dictionary<string, List<Order>>();
foreach (var order in orders){ ref List<Order>? list = ref CollectionsMarshal.GetValueRefOrAddDefault( grouped, order.Region, out bool exists);
if (!exists) list = new List<Order>();
list!.Add(order);}No TryGetValue. No ContainsKey. No double hashing. Just a direct ref into the slot.
A couple of things to keep in mind:
- Don’t modify the dictionary while holding a ref. Adding or removing keys can resize the internal storage and invalidate your reference. Use the ref, then move on.
- It lives in
CollectionsMarshalfor a reason. This is a low-level API. It trades a bit of safety for performance. For casual code, the classicTryGetValuepattern is perfectly fine. Reach for this when profiling tells you dictionary access is a bottleneck. - It only works with
Dictionary<TKey, TValue>, notConcurrentDictionary, notFrozenDictionary, not custom implementations.
For hot-path aggregation, counting, grouping, or caching, CollectionsMarshal.GetValueRefOrAddDefault is a one-line upgrade that cuts your hashing work in half.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
Stop Mocking the Clock: TimeProvider Makes Time Testable
We’ve all been there. You write a unit test that depends on DateTime.UtcNow, and suddenly you’re building a custom IClock interface, or wrapping things in a static ambient context, or just… hoping the test doesn’t run at midnight on a leap year. TimeProvider puts all of that to rest. It’s a first-class abstraction for time, and it lives right in the base class library.
The concept is straightforward. Instead of calling DateTime.UtcNow or DateTimeOffset.UtcNow directly, you inject a TimeProvider and call GetUtcNow(). In production, you register TimeProvider.System, which just returns the real clock. In tests, you swap in a FakeTimeProvider that lets you set, freeze, and advance time whenever you want.
Let’s look at a practical example. Say you have a service that checks whether a coupon code has expired:
public class CouponService(TimeProvider timeProvider){ public bool IsValid(Coupon coupon) { var now = timeProvider.GetUtcNow(); return now < coupon.ExpiresAt; }
public async Task WaitAndExpire(Coupon coupon, CancellationToken ct) { var remaining = coupon.ExpiresAt - timeProvider.GetUtcNow(); if (remaining > TimeSpan.Zero) { await Task.Delay(remaining, timeProvider, ct); } coupon.IsActive = false; }}Notice that Task.Delay overload. It accepts a TimeProvider, so even delays become fully controllable in tests. Registering it in your DI container works the same way as any other service:
builder.Services.AddSingleton(TimeProvider.System);Now for the fun part. Install the testing companion package:
dotnet add package Microsoft.Extensions.TimeProvider.TestingAnd write tests that are completely deterministic. No flaky timing, no Thread.Sleep, no crossing your fingers:
using Microsoft.Extensions.Time.Testing;
public class CouponServiceTests{ [Fact] public void IsValid_ReturnsFalse_AfterExpiration() { // Start at a known point in time var fake = new FakeTimeProvider( new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero));
var coupon = new Coupon { ExpiresAt = fake.GetUtcNow().AddHours(2) };
var service = new CouponService(fake);
Assert.True(service.IsValid(coupon));
// Jump forward 3 hours, instantly fake.Advance(TimeSpan.FromHours(3));
Assert.False(service.IsValid(coupon)); }
[Fact] public async Task WaitAndExpire_DeactivatesCoupon_WhenTimeAdvances() { var fake = new FakeTimeProvider( new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero));
var coupon = new Coupon { ExpiresAt = fake.GetUtcNow().AddMinutes(30), IsActive = true };
var service = new CouponService(fake); var task = service.WaitAndExpire(coupon, CancellationToken.None);
// Time hasn't moved, coupon is still active Assert.True(coupon.IsActive);
// Advance past expiration fake.Advance(TimeSpan.FromMinutes(31));
await task; Assert.False(coupon.IsActive); }}
public class Coupon{ public DateTimeOffset ExpiresAt { get; set; } public bool IsActive { get; set; }}The FakeTimeProvider.Advance() call is the secret sauce. It moves the clock forward instantly, and any pending Task.Delay calls waiting on that TimeProvider will resolve right away. No real waiting, no race conditions, no flaky CI runs.
If you’re still maintaining a homegrown IClock or IDateTimeProvider interface, it might be time to retire it. TimeProvider is baked into the framework, works with Task.Delay, PeriodicTimer, and CancellationTokenSource.CancelAfter, and has a battle-tested fake ready to go. That’s one less abstraction you have to carry around.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.