C# 14 ships with .NET 10 and focuses on removing boilerplate rather than adding new paradigms. It introduces extension members, the field keyword, null-conditional assignment, first-class span conversions, and several smaller changes that tighten everyday code. Some features pay off immediately. Others matter only if you write libraries, source generators, or high-performance code. This guide covers the syntax, the runtime behavior, the breaking-change risks, and the refactors worth doing first.
Quick Takeaways
- Extension members let you declare extension properties, operators, and static members inside an
extensionblock, not only methods. - The
fieldkeyword gives custom property accessors direct access to the compiler-generated backing field. No private field declaration is needed. - Null-conditional assignment (
obj?.Prop = value) removes theif (obj != null)guard from assignments. - Target .NET 10 (
net10.0) with the .NET 10 SDK to use all of it.
| Feature | Primary Benefit | Adoption Effort | Best For |
|---|---|---|---|
| Extension members | Extension properties and static members | Medium | Library authors |
field keyword |
Less backing-field boilerplate | Low | Everyone |
| Null-conditional assignment | Shorter null-safe writes | Low | Everyone |
| Implicit span conversions | Fewer overloads, fewer allocations | Low | Performance code |
nameof on unbound generics |
Cleaner logging and diagnostics | Low | Framework code |
| Lambda parameter modifiers | No explicit types with out/ref |
Low | Delegate-heavy code |
| Partial constructors/events | Source generator friendliness | Medium | Tooling authors |
| User-defined compound assignment | In-place operators, fewer allocations | Medium | Numeric and math types |
Setting Up Your Environment
C# 14 is the default language version for projects that target .NET 10. Install the .NET 10 SDK, then set the target framework:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<!-- LangVersion defaults to 14 on net10.0. Set it explicitly only to pin it. -->
<LangVersion>14</LangVersion>
</PropertyGroup>
</Project>
Running dotnet build with the .NET 10 SDK now compiles every sample below. You can also use Visual Studio 2026, which supports C# 14 out of the box.
Extension Members: Beyond Extension Methods
Classic extension methods use a this modifier on the first parameter of a static method. That syntax cannot express extension properties, extension operators, or static extension members. C# 14 fixes this with the extension block.
Syntax Breakdown
An extension block declares the receiver once. Every member inside it uses that receiver.
public static class StringExtensions
{
// Instance extension members: receiver is a named parameter
extension(string? value)
{
// Extension property
public bool IsNullOrEmpty => string.IsNullOrEmpty(value);
// Extension method, no 'this' modifier needed
public string Truncate(int maxLength) =>
value is null || value.Length <= maxLength
? value ?? string.Empty
: value[..maxLength];
}
// Static extension members: receiver has a type but no name
extension(string)
{
public static string Empty2 => string.Empty;
public static bool IsBlank(string? s) => string.IsNullOrWhiteSpace(s);
}
}
Calling the members looks like calling built-in ones:
string? title = null;
Console.WriteLine(title.IsNullOrEmpty); // True
Console.WriteLine("Hello, world".Truncate(5)); // Hello
Console.WriteLine(string.IsBlank(" ")); // True
The extension property compiles to a static accessor method, so there is no extra allocation or hidden state. The string.IsBlank(...) call resolves to the static extension block, which makes the type’s API surface feel larger without touching the type.
Generic Extension Blocks
The block takes its own type parameters. This replaces repeated <T> declarations on every method.
public static class EnumerableExtensions
{
extension<TSource>(IEnumerable<TSource> source)
{
public bool IsEmpty => !source.Any();
public IEnumerable<TSource> WhereNotNull() =>
source.Where(item => item is not null);
}
}
var names = new List<string?> { "Ada", null, "Linus" };
Console.WriteLine(names.IsEmpty); // False
Console.WriteLine(string.Join(", ", names.WhereNotNull())); // Ada, Linus
Both members share one TSource declaration. The old syntax required <TSource> on each method, so the new form removes repetition once a class has more than two or three extensions.
Extension Methods vs. Extension Members
| Aspect | Classic Extension Method | extension Block |
|---|---|---|
| Declaration | this on first parameter |
Receiver declared on the block |
| Properties | Not supported | Supported |
| Static members | Not supported | Supported |
| Binary compatibility | Established | Classic methods remain valid |
| Generic repetition | Per method | Once per block |
| Migration | None required | Optional, incremental |
Existing extension methods keep working. You do not need a mass migration. Adopt the block syntax for new code and convert files when you add a property.
The field Keyword: Field-Backed Properties
Before C# 14, adding logic to one accessor forced you to declare a private field and write both accessors by hand. The field contextual keyword refers to the compiler-synthesized backing field directly.
Before and After
// C# 13: manual backing field required
public class Customer
{
private string _name = string.Empty;
public string Name
{
get => _name;
set => _name = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
}
}
// C# 14: use 'field' inside the accessor
public class Customer
{
public string Name
{
get;
set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
}
}
The get; accessor stays auto-implemented. Only the set accessor has a body. The compiler generates the backing field and binds field to it.
Lazy Initialization and Change Notification
field shines in two patterns: lazy defaults and INotifyPropertyChanged.
public class Report
{
// Lazy default without a second field
public List<string> Lines => field ??= new List<string>();
}
public class ViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
public string Title
{
get;
set
{
if (field == value) return;
field = value;
PropertyChanged?.Invoke(this, new(nameof(Title)));
}
} = string.Empty;
}
Report.Lines allocates the list on first read only. ViewModel.Title raises the event only when the value actually changes. The = string.Empty initializer assigns directly to the backing field.
Breaking-Change Warning
If your class already has a member named field, the keyword creates ambiguity. Inside an accessor, field now means the backing field. Disambiguate with @field or this.field:
public class Legacy
{
private int field; // existing member
public int Value
{
get => this.field; // refers to the member, not the keyword
set => this.field = value;
}
}
Search your codebase for identifiers named field before upgrading. The compiler warns about the ambiguity, so you will see it at build time.
Null-Conditional Assignment
The ?. and ?[] operators now work on the left side of an assignment. The right-hand side is evaluated only if the receiver is non-null.
public class Order { public decimal Total { get; set; } }
public class Customer { public Order? CurrentOrder { get; set; } }
Customer? customer = GetCustomer();
// C# 13
if (customer is not null)
{
customer.CurrentOrder = CreateOrder();
}
// C# 14
customer?.CurrentOrder = CreateOrder();
customer?.CurrentOrder?.Total += 5m;
If customer is null, CreateOrder() never runs. This short-circuit behavior matters when the right-hand side has side effects or is expensive. Compound assignments (+=, -=) work. The increment and decrement operators (++, --) do not.
Implicit Span Conversions
C# 14 gives Span<T> and ReadOnlySpan<T> first-class language support. Arrays convert implicitly to both, and string converts to ReadOnlySpan<char>. Generic type inference and extension method resolution now see through those conversions.
static int CountVowels(ReadOnlySpan<char> text)
{
int count = 0;
foreach (char c in text)
{
if ("aeiouAEIOU".Contains(c)) count++;
}
return count;
}
string word = "Extension";
char[] buffer = ['s', 'p', 'a', 'n'];
Console.WriteLine(CountVowels(word)); // 3, no Substring, no allocation
Console.WriteLine(CountVowels(buffer)); // 1
Both calls pass without a manual .AsSpan(). The practical gain is fewer duplicate overloads (string, char[], ReadOnlySpan<char>) and fewer accidental allocations. Span-based extension methods also bind directly to arrays now.
nameof with Unbound Generic Types
nameof accepts an unbound generic type, so you no longer need a dummy type argument.
// C# 13
string a = nameof(List<int>); // "List"
// C# 14
string b = nameof(List<>); // "List"
string c = nameof(Dictionary<,>); // "Dictionary"
The output is identical. The new form removes an arbitrary type argument that implied meaning it did not have.
Lambda Parameters with Modifiers
You can now add ref, in, out, scoped, and ref readonly to lambda parameters without writing the types.
delegate bool TryParse<T>(string text, out T result);
// C# 13: types mandatory because of 'out'
TryParse<int> old = (string text, out int result) => int.TryParse(text, out result);
// C# 14: types inferred from the delegate
TryParse<int> parse = (text, out result) => int.TryParse(text, out result);
if (parse("42", out var value))
{
Console.WriteLine(value); // 42
}
Inference comes from the target delegate type. The change removes noise in callback-heavy code and parser tables.
Partial Constructors and Partial Events
Partial members now include instance constructors and events. This helps source generators, which can declare the shape while you or another generator supply the body.
public partial class Widget
{
// Defining declaration
public partial Widget(int size);
}
public partial class Widget
{
private readonly int _size;
// Implementing declaration
public partial Widget(int size)
{
_size = size;
}
}
Only the implementing declaration can include a constructor initializer (this(...) or base(...)). Only one declaration may use primary constructor syntax. Most application developers will not write these by hand, but generator output will use them.
User-Defined Compound Assignment Operators
Before C# 14, a += b compiled as a = a + b, which allocated a new object for reference types. You can now define += and the other compound operators as instance methods that mutate in place.
public class Vector
{
public double[] Values { get; }
public Vector(params double[] values) => Values = values;
// Allocating operator
public static Vector operator +(Vector left, Vector right)
{
var result = new double[left.Values.Length];
for (int i = 0; i < result.Length; i++)
result[i] = left.Values[i] + right.Values[i];
return new Vector(result);
}
// In-place compound operator: instance, void return
public void operator +=(Vector other)
{
for (int i = 0; i < Values.Length; i++)
Values[i] += other.Values[i];
}
}
var a = new Vector(1, 2, 3);
var b = new Vector(4, 5, 6);
a += b; // mutates 'a' in place, zero new Vector allocations
Console.WriteLine(string.Join(", ", a.Values)); // 5, 7, 9
Use this for large buffers, matrices, and big-number types. Keep the in-place operator semantically consistent with the binary operator. Otherwise a = a + b and a += b produce different results and surprise callers.
Best Practices: Bad Code vs. Good Code
Backing Fields
// Bad: manual field exists only to hold one trim call
public class User
{
private string _email = string.Empty;
public string Email
{
get { return _email; }
set { _email = value.Trim(); }
}
}
// Good: field-backed property, same behavior
public class User
{
public string Email
{
get;
set => field = value.Trim();
} = string.Empty;
}
Null-Safe Writes
// Bad: nested guards bury the intent
if (session != null)
{
if (session.User != null)
{
session.User.LastSeen = DateTime.UtcNow;
}
}
// Good: one line, same short-circuit semantics
session?.User?.LastSeen = DateTime.UtcNow;
Extension Helpers
// Bad: method call where a property reads naturally
public static bool GetIsEmpty<T>(this IEnumerable<T> source) => !source.Any();
// Good: extension property
extension<T>(IEnumerable<T> source)
{
public bool IsEmpty => !source.Any();
}
Which Features Are Worth Adopting First?
| Priority | Feature | Reason |
|---|---|---|
| 1 | field keyword |
Immediate boilerplate reduction, low risk |
| 2 | Null-conditional assignment | Safer, shorter code in every project |
| 3 | Implicit span conversions | Free performance on hot paths |
| 4 | Extension members | Best for shared libraries and API design |
| 5 | Compound operators | Only for allocation-sensitive numeric types |
| 6 | Partial constructors/events | Only if you author source generators |
Start with field and ?. assignment, since they carry the lowest migration risk. Introduce extension blocks when you next add an extension property. Treat compound operators as an optimization to apply after profiling shows allocation pressure.
Upgrade Checklist
- Install the .NET 10 SDK and set
<TargetFramework>net10.0</TargetFramework>. - Search for identifiers named
fieldinside property accessors. - Rebuild and review new warnings, especially around overload resolution with spans.
- Convert one class to
field-backed properties and run your tests. - Replace guard-then-assign blocks with
?.assignment. - Re-run benchmarks on span-heavy code paths to confirm overload changes.
FAQ
Which .NET version supports C# 14?
.NET 10 supports C# 14. Install the .NET 10 SDK and target net10.0. C# 14 is the default language version for that target.
Does the field keyword break existing code?
Only if a class has a member named field and an accessor refers to it by simple name. The compiler warns about the ambiguity. Fix it with @field or this.field.
Do extension blocks replace classic extension methods?
No. Classic this-parameter extension methods remain fully supported. Extension blocks add properties, operators, and static members, and they reduce repetition when a class has many extensions.
Does null-conditional assignment work with ++ and --?
No. It works with simple assignment and compound assignments such as +=, but not increment or decrement operators.




