C# 14 Is Actually Good And I'm Not Happy About It
C# 14's most significant new features explained — what changed, why it matters, and why the language that used to mean enterprise bloat is now genuinely good.
Let me tell you something about C#. For years – years – it was the language I pointed at when I wanted to explain what "enterprise bloat" looked like. You want to print "Hello World"? Here's your namespace. Here's your class. Here's your static void Main. Here's your Program.cs. Here's your .csproj file. Here's your solution file. Here's Visual Studio loading for 45 seconds. Now you can print "Hello World."
And I say all of that as someone who has genuinely enjoyed writing C#. The language itself has been on a trajectory that I can only describe as "Microsoft slowly realizing that their developers also have eyes and feelings." C# 9 brought records. C# 10 brought global usings. C# 12 brought primary constructors. Every release they chip away a little more of the ceremony.
C# 14 – shipping with .NET 10 – might be the release where they finally got me. There are features in here that I did not know I wanted, and now that I've seen them, I cannot unsee them. Let me walk you through the ones that actually matter.
They Gave C# a REPL For Adults
Okay. Okay okay okay. This one. THIS one.
For as long as C# has existed, running any C# code – even a five-line script, even a quick test, even "I just want to verify this one thing" – required the full ceremony. You need a .csproj. You need a solution file if you're in Visual Studio. You need your namespace, your class, your static void Main(string[] args). And then, THEN, you could run your five lines of code.
Compare that to Python. python hello.py. Done. Go home.
Compare that to Node. node hello.js. Done.
C# developers have been jealous of this for fifteen years and they've just had to live with it. C# 14 fixes this. You can now run a .cs file directly from the command line:
dotnet run Demo.cs
That's it. No project file. No solution file. No class. No Main method. Just this:
Console.WriteLine("This is a sample text");
DateTime dateTime = DateTime.UtcNow.Date;
Console.WriteLine($"Today's date is: {dateTime.ToString("d")}");
Save it as Demo.cs. Run dotnet run Demo.cs. You're done.
"But wait," I hear you say, "what about NuGet packages?" Because obviously the first thing everyone's going to try to do is add a dependency and watch it explode. They thought of this. You can reference NuGet packages and SDKs using preprocessor directives in the file itself. File-based apps that need external packages are supported out of the box. This is not just a toy feature for beginner tutorials. This is a legitimate scripting solution for C#.
The use cases here are enormous. CI/CD automation scripts where you want the safety of a typed language without the overhead of setting up a full project. Quick data processing one-offs. Prototyping ideas without committing to a project structure. Sharing self-contained code snippets with colleagues. Teaching C# to beginners without making them understand project files on day one.
The .NET team has essentially given C# the ability to compete with Python scripts for the "I need to automate this thing real quick" use case. And honestly? Given that C# is a much better language for avoiding runtime explosions, this is a huge deal.
I will be using this constantly. I'm already thinking of three scripts I've been writing in Python out of habit that I now want to rewrite in C# just because I can.
The Feature C# Has Been Trying to Have for a Decade
This one is the crown jewel. Extension methods have been in C# since version 3.0. They're one of those features that you use constantly and appreciate, but they've always had a limitation that gnawed at the back of your brain: you could extend methods, but you couldn't extend properties.
Think about it. You've got a list. You want to add an IsEmpty property to IEnumerable without touching the interface. In C# 13? You can't. You have to write an extension method called IsEmpty() with parentheses, which looks and feels wrong because IsEmpty is a property, not something that does work. It should be myList.IsEmpty, not myList.IsEmpty().
C# 14 adds extension members, which includes extension properties. And the syntax is actually clean:
public static class Enumerable
{
extension<TSource>(IEnumerable<TSource> source)
{
// Extension PROPERTY - no parentheses needed
public bool IsEmpty => !source.Any();
// Extension method - same block, cleaner grouping
public IEnumerable<TSource> Where(Func<TSource, bool> predicate) { ... }
}
}
See that extension block? That's the new syntax. You declare the receiver type once – IEnumerable<TSource> in this case – and then everything inside that block is an extension member for that type. Property, method, doesn't matter.
But here's where it gets even more interesting. You can also declare static extension members:
extension<TSource>(IEnumerable<TSource>) // No parameter name = static
{
// Static extension method
public static IEnumerable<TSource> Combine(IEnumerable<TSource> first, IEnumerable<TSource> second) { ... }
// Static extension property
public static IEnumerable<TSource> Identity => Enumerable.Empty<TSource>();
// And here's the one that's going to blow some minds...
// Static user-defined OPERATOR as an extension
public static IEnumerable<TSource> operator +(IEnumerable<TSource> left, IEnumerable<TSource> right)
=> left.Concat(right);
}
Wait. WAIT. You can now add operators to types as extension members? So you can write code where you add two IEnumerables with + without modifying IEnumerable at all? That's... that's actually powerful. That's the kind of expressiveness that lets you write domain-specific code that reads like the domain itself.
The old extension method syntax still works. Your existing code doesn't break. But you can now mix old-style this parameter methods with the new block syntax in the same static class. You don't have to choose.
The other thing worth noting is the grouping semantics. Before, if you had twenty extension methods for string, they were just twenty separate static methods that happened to share a file. Now you can put them inside an extension(string value) block and they're visually and semantically grouped. Your code is organized around the type being extended, which is exactly how your brain thinks about it.
This is the feature that should have been in C# 3.0 when extension methods launched. Better late than never.
The field Keyword
Here's a thing that every C# developer has written approximately ten thousand times:
private string _message;
public string Message
{
get => _message;
set => _message = value ?? throw new ArgumentNullException(nameof(value));
}
Why does _message exist? It exists purely to support the property. It's a private field that has one job: be the backing store for Message. Nobody outside this class touches _message. It only exists because the property needs somewhere to put the value.
C# 14 introduces the field keyword, which is a compiler-synthesized backing field. You don't declare it. You just use it:
public string Message
{
get;
set => field = value ?? throw new ArgumentNullException(nameof(value));
}
The private backing field still exists. The compiler generates it for you. field inside the accessor refers to that compiler-generated field. You get the same behavior, same null check, same exception – but you've removed the private field declaration and simplified the getter to an auto-property.
This is an incremental quality-of-life improvement, but the cases where it shines are real. Any time you want one custom accessor and one automatic accessor, you've traditionally needed to write out both accessors in full plus declare a backing field. With field, you can have an auto get; and a custom set, or vice versa. Clean.
The one caveat: if you have a field named field anywhere in your type, you'll get ambiguity. The team handled this – you can use @field or this.field to disambiguate. It's a bit awkward but it's a corner case most codebases will never hit.
Null-Conditional Assignment
This one's small but it's the kind of small thing that makes you go "why didn't this exist before?"
You know ?.. You use it for null-safe member access all the time. customer?.Name returns null if customer is null, otherwise returns the name. Safe, clean, everywhere.
Before C# 14, you could NOT use ?. on the left side of an assignment. So if you wanted to set a property only when the object wasn't null, you had to write:
if (customer is not null)
{
customer.Order = GetCurrentOrder();
}
Three lines. A whole conditional block. For what is conceptually a single operation.
C# 14 lets you write:
customer?.Order = GetCurrentOrder();
One line. Does exactly what you'd expect – assigns to Order only if customer isn't null. And critically, GetCurrentOrder() on the right side is only evaluated if the left side isn't null. So you're not calling expensive methods for nothing.
This works with compound assignment too. customer?.RunningTotal += orderAmount; – only adds to the total if customer exists. The one exception: ++ and -- are not supported. But +=, -=, *=, and the rest? All work.
This is a feature you're going to use the first week and then completely forget exists because it becomes muscle memory instantly.
Lambda Parameters with Modifiers
If you've ever tried to write a lambda that takes an out parameter, you know the pain. You wanted to write this:
TryParse<int> parse = (text, out result) => Int32.TryParse(text, out result);
C# said no. You had to write this:
TryParse<int> parse = (string text, out int result) => Int32.TryParse(text, out result);
The moment you added any modifier to any parameter, you had to specify all types explicitly. Even when the types were completely obvious from context. Even when the compiler could trivially infer them. You had to write them out anyway.
C# 14 fixes this. You can now add modifiers like out, ref, in, scoped, and ref readonly to lambda parameters without specifying types:
TryParse<int> parse = (text, out result) => Int32.TryParse(text, out result);
The types are inferred from the delegate type. This is how it should have always worked. There's a small asterisk: params still requires explicit types. But for the common cases – especially out – this removes a genuinely annoying ceremony.
nameof With Unbound Generics
Small one, but worth mentioning because it's been a minor annoyance for years.
nameof is great. Using nameof(MyProperty) instead of the string "MyProperty" gives you compile-time safety, refactoring support, and the warm feeling of not having magic strings in your code.
But before C# 14, nameof required a closed generic type. So you couldn't write nameof(List<>). You had to write nameof(List<int>) or nameof(List<object>) – pick any type argument, it didn't matter which, you just needed one. It was a dummy argument whose sole purpose was satisfying the compiler.
C# 14 just... lets you write nameof(List<>). And nameof(Dictionary<,>) for two type parameters. The result is "List" and "Dictionary" respectively, which is what you wanted in the first place.
Console.WriteLine(nameof(List<>)); // "List"
Console.WriteLine(nameof(Dictionary<,>)); // "Dictionary"
This matters most in logging, reflection-adjacent code, and error messages where you want the generic type name without a specific instantiation. No more dummy type arguments. Done.
User-Defined Compound Assignment Operators
Before C# 14, if you wanted to overload + for your type, you overloaded +. And then C# derived += from your overloaded + automatically. The compound assignment operator was synthesized. You had no say.
This meant that x += y was literally compiled as x = x + y. For value types this is fine. For reference types, this means a whole new object allocation and assignment. If your type has a mutable += that doesn't need to construct a new instance, you had no way to express that at the language level. The compiler would ignore whatever you tried.
C# 14 adds support for directly overloading compound assignment operators. You can now write:
public class ShoppingCart
{
public int TotalQuantity { get; private set; } = 0;
public decimal TotalAmount { get; private set; } = 0m;
public void operator +=(int quantity)
{
TotalQuantity += quantity;
}
public void operator +=(decimal amount)
{
TotalAmount += amount;
}
}
And use it like:
var cart = new ShoppingCart();
cart += 3; // Calls the int overload, mutates TotalQuantity
cart += 49.99m; // Calls the decimal overload, mutates TotalAmount
This is genuinely useful for mutable aggregate types where the semantics of "add to this" are distinct from "create a new thing that is the sum of these two things." Financial types, game state, collection wrappers – anywhere you have a meaningful in-place mutation operation.
The supported operators are: +=, -=, *=, /=, %=, &=, |=, ^=, <<=, and >>=. Basically everything that previously had a compound form.
Span<T> Gets First-Class Language Support
This one's more technical but it matters for performance-sensitive code. Span<T> and ReadOnlySpan<T> are Microsoft's answer to working with contiguous memory regions without allocating. They're the backbone of high-performance .NET: parsing, serialization, string manipulation at speed, zero-copy operations.
The problem has been that Span<T> was always a bit awkward to work with because the language didn't have special knowledge of it. You'd hit type inference failures, you couldn't use spans as extension method receivers in all the contexts you'd expect, and you'd get conversion errors that required explicit casting.
C# 14 bakes first-class span support into the language itself. There are new implicit conversions between ReadOnlySpan<T>, Span<T>, and T[]. Spans work as extension method receivers. They compose with other conversions. Generic type inference gets smarter about spans.
If you're writing performance-sensitive code – parsing, serialization, game dev, anything with tight inner loops – this reduces the friction significantly. You spend less time fighting the compiler and more time actually working with memory.
Partial Constructors and Partial Events
This one's for the code generation crowd. Partial classes and partial methods have been in C# for a while – they exist primarily for scenarios where code generation tools produce part of a class and you write the other part. Source generators love this.
C# 14 extends partial member support to include constructors and events. This might not change your daily life if you're not doing source generation, but for library authors building generators? This is meaningful. You can now generate the declaration of a constructor or event in one partial, and let users or the framework provide the implementation in another.
The Conclusion
Here's the thing about C# 14 that I keep coming back to: none of these features are gimmicks. There's no feature in this release that feels like "we needed to ship something." Every single one of these addresses a real friction point that C# developers hit regularly.
File-based apps removes the biggest barrier to using C# for scripting. Extension members finally completes the extension member story that's been half-told since C# 3.0. The field keyword reduces property boilerplate. Null-conditional assignment handles a common pattern in one character. Lambda modifiers without types stop punishing you for using out. nameof with unbound generics is just obviously correct. User-defined compound assignment makes mutating reference types a first-class operation. Span gets native support.
Is C# still more verbose than some languages? Yes. Is the .NET ecosystem still enormous and occasionally labyrinthine? Absolutely. But the language team has been on a consistent, coherent arc of "remove friction, increase expressiveness, don't break things." And C# 14 is the sharpest point on that arc so far.
To try all of this, you need .NET 10 and Visual Studio 2026 (or you can use the .NET 10 SDK with VS Code). Change your TargetFramework to net10.0 in your .csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<LangVersion>preview</LangVersion>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Or, and I cannot believe I'm saying this about C#, just write a .cs file and dotnet run it.
The language I used to point at for ceremony has become genuinely expressive, incrementally, version by version, without breaking the ecosystem. That's harder than it sounds. The C# team deserves credit for it.
Go install .NET 10. Go break things. And definitely try dotnet run YourFile.cs at least once, just for the feeling.