For years my little string helpers lived in a static class, and every call read name.IsSlugOld() with those parentheses that always bugged me. It's a question about the string, not something I'm doing to the string. I wanted to write name.IsSlug and have it look like what it is: a property. You couldn't, though. Extension methods were methods, full stop. C# 14 finally lets you write an extension property, so I spent an evening moving some helpers over and making sure the nicer syntax wasn't quietly charging me for the privilege.
Here's the thing I started with:
static class OldExtensions { public static bool IsSlugOld(this string s) { if (s.Length == 0) return false; foreach (var c in s) if (!(char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c) || c == '-')) return false; return true; } } Nothing wrong with it. It just reads like a verb when it's really an adjective.
The new block
static class SlugExtensions { extension(string s) { public bool IsSlug { get { if (s.Length == 0) return false; foreach (var c in s) if (!(char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c) || c == '-')) return false; return true; } } } } The receiver moved. The old (this string s) is gone from the signature; the type and the parameter name now sit up in the extension(string s) header, and everything inside the block is written as if s were already in scope. Under the hood it lowers to the same static method taking a string that it always did. The difference is that the block can hold a property, which a plain extension method could never be.
You can also drop the parameter name to get static members:
extension(string) { public static string ToSlug(string input) { Span<char> buffer = stackalloc char[input.Length]; int n = 0; foreach (var c in input) { if (char.IsAsciiLetterUpper(c)) buffer[n++] = char.ToLowerInvariant(c); else if (char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c)) buffer[n++] = c; else if (c == ' ' || c == '_') buffer[n++] = '-'; } return new string(buffer[..n]); } } Now string.ToSlug("Hello, World!") resolves to my code, hanging off the type itself with no instance anywhere. That one genuinely wasn't possible before. The closest you had was a separate SlugFactory class sitting off to the side, which is exactly the kind of thing I enjoy deleting.
Both read the same way the old method did, and they agree with it:
input old method new property "hello-world" True True "Not A Slug" False False "post-115" True True "" False False "trailing-" True True string.ToSlug("Hello, World!") => "hello-world" Did it cost anything
This is the part I actually cared about. New syntax has a way of hiding a new indirection, and a property getter you call in a hot loop is exactly where you'd feel one. So I ran the old method against the new property, 50 million calls each, best of 5, warmed up first, with a mix of inputs so the branch predictor couldn't just memorise one answer. Cloud container, not a lab bench. I care about the ratio, not the millisecond.
50,000,000 calls, best of 5: old method 181.7 ms allocated 40 B new property 182.6 ms allocated 0 B ratio new/old 1.005 A tie. I ran it a dozen times and the ratio sat between 1.00 and 1.01, which at this scale is just noise. The 40 bytes on the old row is one-time startup junk, not something per call. And that's the whole finding: the property getter lowers to the same static call the method always made, so there's nothing left over to be slower. I half expected to pay a few percent for the nicer read. I paid nothing, which is the boring kind of result I trust more than a flashy one.
The one that bit me
Here's where I lost twenty minutes. Say the type already has a member with the name you're reaching for:
sealed class Tag { public string Name { get; } public Tag(string name) => Name = name; public bool IsSlug => Name.IsSlug; // instance property } static class TagExtensions { extension(Tag t) { public bool IsSlug => throw new InvalidOperationException("extension IsSlug ran"); } } new Tag("hello-world").IsSlug prints True and never throws. The instance member wins and the extension is never even considered. That's the same rule extension methods always had, where a real method beats an extension, but it's much easier to trip over with properties. A property named IsSlug looks so much like the one thing you defined that you don't think to check. There's no warning either. Your extension just quietly doesn't run. If you're adding extension properties to a type you also own, that's the trap: the day a teammate adds an instance member with the same name, your extension goes dark and nothing tells you.
My rule after this: if the member does real work, takes arguments, has side effects, or might throw, leave it a method. A property should feel free and safe to read. IsSlug walking a short string is fine. A DownloadReport property would be a lie. C# 14 doesn't change that judgment; it just finally lets you make the call instead of deciding for you.
Here's the harness I timed with, so you can check the tie yourself. Drop it next to the two classes above in a project that targets net10.0 with <LangVersion>14.0</LangVersion>, then dotnet run -c Release:
static void Benchmark() { const int iterations = 50_000_000; string[] inputs = ["hello-world-115", "Definitely Not", "x", "a-b-c-d-e-f"]; long warm = 0; // warm the JIT for both paths before we time anything for (int i = 0; i < 1_000_000; i++) { if (inputs[i & 3].IsSlugOld()) warm++; if (inputs[i & 3].IsSlug) warm++; } double oldBest = double.MaxValue, newBest = double.MaxValue; long oldAlloc = 0, newAlloc = 0; for (int run = 0; run < 5; run++) { long a0 = GC.GetAllocatedBytesForCurrentThread(); var sw = Stopwatch.StartNew(); long acc = 0; for (int i = 0; i < iterations; i++) if (inputs[i & 3].IsSlugOld()) acc++; sw.Stop(); oldAlloc = GC.GetAllocatedBytesForCurrentThread() - a0; oldBest = Math.Min(oldBest, sw.Elapsed.TotalMilliseconds); GC.KeepAlive(acc); a0 = GC.GetAllocatedBytesForCurrentThread(); sw.Restart(); acc = 0; for (int i = 0; i < iterations; i++) if (inputs[i & 3].IsSlug) acc++; sw.Stop(); newAlloc = GC.GetAllocatedBytesForCurrentThread() - a0; newBest = Math.Min(newBest, sw.Elapsed.TotalMilliseconds); GC.KeepAlive(acc); } Console.WriteLine($"old {oldBest:F1} ms ({oldAlloc} B) new {newBest:F1} ms ({newAlloc} B) ratio {newBest / oldBest:F3}"); } (My GitHub mirror usually carries the full project, but the push was blocked in the environment I ran this from, so the code lives here in the post this time.)
Anyone moved old static helpers into extension blocks yet, or are you holding off until the tooling settles? Curious what's keeping people on the fence.
— still benchmarking syntax sugar nobody asked me to
