Use C# 14 extension properties only where your own code reads them

C# 14 finally lets you drop the brackets. The order.IsOverdue() extension method you've had for years can become order.IsOverdue, a property, on a class you don't even own. The docs' own example is an IsEmpty property on any sequence. At the call site you can't tell it apart from a real property, and that's the appeal.
The catch is that an extension property compiles to a static method, so anything that inspects the type can't see it. I'd use them for reads in my own C# code, and keep anything a query, a serializer or an API contract needs as a real property, or as an expression the query can run.
Writing one
I wanted to see exactly where the illusion breaks. So I built a small .NET 10 console app with EF Core 10 on SQLite, an Order entity with a due date and a paid date, and this:
public static class OrderExtensions
{
extension(Order o)
{
public bool IsOverdue => o.PaidOn is null && o.DueDate < DateTime.UtcNow;
}
}
The extension(Order o) block is the new syntax. Everything inside it gets added to Order, and o is the instance it's called on. In plain code it works like any property: Ada's order is unpaid and was due in 2020, so ada.IsOverdue is true, and the paid order and the one due in 2099 come back false.
Where it disappears
Then I put it in a query, db.Orders.Where(o => o.IsOverdue). I expected trouble, and I got it before the app ran:
error CS9296: An expression tree may not contain an extension property access
An expression tree is the lambda kept as data, which is what EF Core reads to write SQL. The compiler won't put an extension property in one at all, so this isn't an EF Core gap. Any expression tree that reads it gets the same error.
The surprise was the old extension method in the same spot. Where(o => o.IsOverdue()) compiles with zero warnings, then throws the first time the query runs:
System.InvalidOperationException: The LINQ expression 'DbSet<Order>()
.Where(o => o
.IsOverdue())' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.
So in a query the new property is the safer of the two. I went in thinking it would be the risky one. The compile error shows up while you're still typing. The method's mistake waits until that query actually runs.
JSON is quieter. I checked that Ada's IsOverdue was true, then serialized her order with System.Text.Json and got {"Id":1,"Customer":"Ada","DueDate":"2020-01-31T00:00:00","PaidOn":null}. No error, no warning, and no IsOverdue. A client that expects that field just doesn't get it.
Reflection shows why. typeof(Order).GetProperty("IsOverdue") returns null. Asking for all the properties gives Id, Customer, DueDate and PaidOn. The getter lives on OrderExtensions as a static method, get_IsOverdue(Order), that takes the order as a parameter. The compiler also emits a nested type with a generated name that declares the property, for other compilers to read. Nothing that inspects Order looks there, and the serializer only looks at Order.
if (order.IsOverdue)db.Orders.Where(o => o.IsOverdue)JsonSerializer.Serialize(ada)When the type gets a real one
The pitch is adding properties to types you don't own, so I tried what happens when the owner catches up. I gave an Invoice class a real IsOverdue that returns false, and kept my extension property of the same name next to it. With a 2020 due date, my rule says true.
The build was clean and invoice.IsOverdue printed False. The real member wins, and the compiler doesn't mention that your extension is now dead code. After a package upgrade, that's a change in behaviour nobody gets to review. An old extension method loses to a real method the same way (I tried that too), but there the brackets at least hint that it might be something else.
That's why I wouldn't convert existing extension methods to properties in bulk. You save two characters per call and touch every file that calls them. The silent takeover risk stays exactly where it was, it just gets harder to spot in a diff.
What I'd write instead
When a query needs the rule, I keep the rule as an expression and build the property from it:
public static class OrderRules
{
public static readonly Expression<Func<Order, bool>> Overdue =
o => o.PaidOn == null && o.DueDate < DateTime.UtcNow;
private static readonly Func<Order, bool> overdue = Overdue.Compile();
extension(Order o)
{
public bool IsOverdue => overdue(o);
}
}
Queries call db.Orders.Where(OrderRules.Overdue). EF Core turns that into a WHERE clause that checks PaidOn IS NULL and compares the due date with SQLite's current time. Plain code still reads order.IsOverdue, both returned Ada and nobody else, and the rule is written once.
A computed column would work too, but then the rule lives in a migration, and a new Order in memory has no value until it's saved. For a field the API has to send, I'd put a real IsOverdue on the response DTO and set it where the DTO gets built.
Where extension properties do earn their place is small reads on types you don't control, like IsEmpty on a sequence or a FullName on a DTO that a code generator rewrites on every build. For each new one, I'd ask who reads it. If it's only your own C# code, it's fine and reads nicely. If a query, a serializer or someone else's contract reads it, it has to be a real member or an expression, because none of those look where it lives.