This post introduces extension properties, a new feature of C# 14 that allows you to use properties in your extension methods.
Since C# 3 dropped in 2007, C# developers have been using extension methods to add functionality to types without touching their source code. Whether you’re extending types from the .NET BCL, third-party libraries or even your own legacy code, extension methods have been essential to how we write C# code.
But there’s always been one big limitation: you can only extend methods, not properties. The community has been impatiently waiting for that day to come.
With C# 14, that changes. With .NET 10, Microsoft introduced extension members, an expansion of the extension pattern that brings extension properties, extension indexers and even static extension members to the language.
Let’s make sure we’re all on the same page. Extension methods let you “add” methods to existing types without modifying them or creating derived types.
Here’s a classic example:
public static class StringExtensions
{
// Note the 'this' keyword on the first parameter
public static string Capitalize(this string value)
{
if (string.IsNullOrEmpty(value))
return value;
return char.ToUpper(value[0]) + value.Substring(1).ToLower();
}
}
// We call it as if it's an instance method of string
string name = "sara";
string capitalized = name.Capitalize(); // Returns "Sara"
The magic happens with the this keyword on the first parameter, which tells the compiler this is an extension method. Even though Capitalize is a static method in a static class, you call it as if it is an instance method on the string type (which we call the receiver type). IntelliSense picks it up and everyone’s happy.
Extension methods are a core part of the .NET ecosystem and can be found in virtually every code base. For example, the classic LINQ methods like Where, Select and OrderBy are all extension methods that make collections feel like they have built-in query capabilities. Even so, they are not without their limitations.
We all love extension methods. However, extension methods have always been limited to … well, methods.
Let’s say you’re working with an API client and you’re working with HTTP responses all day. You find yourself constantly checking status codes:
var response = await httpClient.GetAsync(url);
if ((int)response.StatusCode >= 200 && (int)response.StatusCode < 300)
{
Console.WriteLine("Success!");
}
else if ((int)response.StatusCode >= 400 && (int)response.StatusCode < 500)
{
Console.WriteLine("Client error - check your request");
}
That’s … not great. So you do what any good C# developer would do: create extension methods.
public static class HttpStatusCodeExtensions
{
public static bool IsSuccess(this HttpStatusCode status)
{
return (int)status >= 200 && (int)status < 300;
}
public static bool IsClientError(this HttpStatusCode status)
{
return (int)status >= 400 && (int)status < 500;
}
}
if (response.StatusCode.IsSuccess())
{
Console.WriteLine("Success!");
}
This is better, but something feels off. You’re calling IsSuccess() with parentheses, like it’s doing work. But it’s not really an action; it’s a characteristic of the status code. It cries out, “What are you?” and not, “What can you do?”
At the risk of going back to CS101, methods represent actions and properties represent attributes and characteristics. When you want to know if a number is positive, you don’t call number.IsPositive(). You’d naturally expect number.IsPositive.
For almost two decades, we’ve been writing methods when we wanted properties. We’ve added Get prefixes, used parentheses where they don’t belong and generally worked around this limitation.
C# 14 addresses this limitation with extension members. This new syntax introduces a dedicated extension block that allows you to define multiple kinds of extension members for a type, including properties, indexers and static members.
Note: Your extension methods aren’t going anywhere and will continue to work in C# 14 in beyond. You’ll be able to use static extension methods, and the
thiskeyword as you have before.
Here’s how it works. Instead of individual extension methods, you create an extension block.
public static class HttpStatusCodeExtensions
{
extension(HttpStatusCode status)
{
public bool IsSuccess => (int)status >= 200 && (int)status < 300;
public bool IsRedirect => (int)status >= 300 && (int)status < 400;
public bool IsClientError => (int)status >= 400 && (int)status < 500;
public bool IsServerError => (int)status >= 500 && (int)status < 600;
}
}
var response = await httpClient.GetAsync(url);
if (response.StatusCode.IsSuccess)
{
Console.WriteLine("Success!");
}
else if (response.StatusCode.IsClientError)
{
Console.WriteLine("Client error - check your request");
}
Look at that. No parentheses. It reads like IsSuccess is an actual property of HttpStatusCode. Because now it is.
The extension declaration creates a block where status is your receiver (the instance you’re extending). Everything inside can access status just like you’d access this in a regular class.
I probably should have warned you about the extension block. That might take some getting used to, and I’m not alone. Many developers find it ugly and verbose, and I did at first. But much like the switch expression syntax, it’s growing on me. (And after waiting almost two decades for this, I’ll take what I can get.)
Plus, once you group related extension members together, it starts to make sense:
extension(HttpStatusCode status)
{
// Properties for checking categories
public bool IsSuccess => (int)status >= 200 && (int)status < 300;
public bool IsClientError => (int)status >= 400 && (int)status < 500;
// Property that uses other extension properties
public string Category => status switch
{
_ when status.IsSuccess => "Success",
_ when status.IsClientError => "Client Error",
_ => "Unknown"
};
}
In this example, everything related to HttpStatusCode extensions lives in one place.
According to the C# team, they explored many different design considerations: putting the receiver on every member (too repetitive), attaching the receiver to the static class (breaks existing patterns), extending this for properties (just, no), and other approaches that had breaking changes or complicated implementations.
The extension block won out because it doesn’t break existing code (your regular extension methods keep working); plus, it’s flexible, allows grouping and provides a place for the receiver to live. After all, since properties don’t have parameters, where else would you declare a type you’re extending?
Why stop there? This new feature supports static extension members, opening up new patterns for extending types.
You can define static extension members in an extension block without a receiver instance.
Let’s add some factory methods and constants to HttpStatusCode:
public static class HttpStatusCodeExtensions
{
// Static extension members - no instance needed
extension(HttpStatusCode)
{
public static HttpStatusCode OK => HttpStatusCode.OK;
public static HttpStatusCode NotFound => HttpStatusCode.NotFound;
public static HttpStatusCode BadRequest => HttpStatusCode.BadRequest;
public static HttpStatusCode FromInt(int code) => (HttpStatusCode)code;
}
}
// Use them like built-in static members
var status = HttpStatusCode.OK;
var notFound = HttpStatusCode.NotFound;
var teapot = HttpStatusCode.FromInt(418); // I'm a teapot!
Notice the difference here? No receiver instance is in the extension declaration, just extension(HttpStatusCode). This lets you add static members that feel like they belong to the type itself.
This is perfect for factory patterns, type-specific constants or utility methods that don’t need an instance.
Let’s put it all together and build something useful. We’ll create a full set of extension members for working with HTTP status codes:
public static class HttpStatusCodeExtensions
{
// Static extension members for common codes
extension(HttpStatusCode)
{
public static HttpStatusCode OK => HttpStatusCode.OK;
public static HttpStatusCode NotFound => HttpStatusCode.NotFound;
public static HttpStatusCode BadRequest => HttpStatusCode.BadRequest;
public static HttpStatusCode FromInt(int code) => (HttpStatusCode)code;
}
// Instance extension properties
extension(HttpStatusCode status)
{
public bool IsSuccess => (int)status >= 200 && (int)status < 300;
public bool IsRedirect => (int)status >= 300 && (int)status < 400;
public bool IsClientError => (int)status >= 400 && (int)status < 500;
public bool IsServerError => (int)status >= 500 && (int)status < 600;
public string Category => status switch
{
_ when status.IsSuccess => "Success",
_ when status.IsRedirect => "Redirect",
_ when status.IsClientError => "Client Error",
_ when status.IsServerError => "Server Error",
_ => "Unknown"
};
}
}
Now look at how clean your HTTP client code becomes:
var response = await httpClient.GetAsync(url);
if (response.StatusCode.IsSuccess)
{
var data = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Success! Category: {response.StatusCode.Category}");
}
else if (response.StatusCode.IsClientError)
{
Console.WriteLine("Client error - check your request");
}
else if (response.StatusCode.IsServerError)
{
Console.WriteLine("Server error - try again later");
}
Once you start working through extension properties, you’ll find uses everywhere.
public static class StringExtensions
{
extension(string str)
{
public bool IsEmpty => string.IsNullOrEmpty(str);
public bool IsWhitespace => string.IsNullOrWhiteSpace(str);
public bool IsValidEmail => !str.IsEmpty &&
str.Contains("@") && str.Contains(".");
public bool IsNumeric => !str.IsEmpty && str.All(char.IsDigit);
}
}
string email = "sara@peloton.com";
if (!email.IsEmpty && email.IsValidEmail)
{
Console.WriteLine("Valid email!");
}
public static class CollectionExtensions
{
extension<T>(ICollection<T> collection)
{
public bool IsEmpty => collection.Count == 0;
public bool HasMultipleItems => collection.Count > 1;
public bool HasSingleItem => collection.Count == 1;
}
}
var items = new List<string> { "apple", "banana" };
// No more items.Count > 1
if (items.HasMultipleItems)
{
Console.WriteLine("Multiple items found");
}
public static class TaskExtensions
{
extension<T>(Task<T>)
{
public static Task<T> Cancelled =>
Task.FromCanceled<T>(new CancellationToken(true));
public static Task<T> Completed(T result) =>
Task.FromResult(result);
}
extension<T>(Task<T> task)
{
public bool IsCompleted => task.IsCompletedSuccessfully;
public bool HasFailed => task.IsFaulted;
public Exception Error => task.Exception?.InnerException;
}
}
var task = Task<int>.Completed(42);
if (task.IsCompleted)
{
Console.WriteLine("All done!");
}
You’re right to question performance with new language features. Luckily, extension members are syntactic sugar that compiles down to regular method calls, just like traditional extension methods. The CLR doesn’t know (or care) that you used extension syntax.
Accessing response.StatusCode.IsSuccess generates identical IL code to calling an IsSuccess() method.
While extension members are great, you’ll want to know about a few limitations:
public string Status { get; set; } won’t work because they technically require backing fields.SelectLessThan<TResult, T> it’ll have to stay as an extension method.Extension members, to me, aren’t just a nice-to-have feature. They change how we think about extending C# types.
Think of API design: when you’re writing libraries, you can provide extension points that finally feel like part of the type. Users won’t have to remember if something is a method or a property. It’ll be whatever makes most sense.
With C# 14 and .NET 10, we finally have extension properties. We can now extend types with the syntax that makes sense. We can stop pretending that characteristics are actions.
Is the extension block weird and different? Yes. Will we get used to it? Probably.
How will you use extension members? Let me know in the comments, and happy coding!
Dave Brock is a software engineer, writer, speaker, open-source contributor and former Microsoft MVP. With a focus on Microsoft technologies, Dave enjoys advocating for modern and sustainable cloud-based solutions.