C#¶
Targets: net10.0, csharp-14 · Last reviewed: 2026-08-18 · Sources: ms-learn, dotnet-blog, jetbrains-dotnet, andrew-lock, meziantou, jon-skeet
Target C# 14 (ships with .NET 10). Adopt its features where they replace an older idiom, not everywhere they compile.
Opinions¶
Extension members¶
Use extension members (extension blocks) instead of scattered static extension-method classes. C# 14's extension blocks group extension methods, properties, and operators for a receiver type in one place, declare the receiver once, and allow extension properties, which the old this-parameter syntax cannot express.
Before (C# 13 and earlier):
public static class OrderExtensions
{
public static bool IsOpen(this Order order) => order.ClosedAt is null;
public static decimal Total(this Order order) => order.Lines.Sum(l => l.Price);
}
After (C# 14):
public static class OrderExtensions
{
extension(Order order)
{
public bool IsOpen => order.ClosedAt is null;
public decimal Total => order.Lines.Sum(l => l.Price);
}
}
Use an extension member when the operation is a pure query or transform over a type you don't own. Where you do own the type, an instance method is the default; the exception worth making is keeping a domain entity a plain data holder, with validation, formatting and mapping outside it. Use the property form when the member reads as a fact about the instance rather than an action: IsEmpty and WordCount were awkward as calls and read naturally as properties. Converting a method to a property breaks every call site, so do it while they are already in hand. (.NET Blog: Introducing C# 14, Microsoft Learn: What's new in C# 14, .NET Blog: C# 14 - Exploring extension members, Microsoft Learn: Extension methods design guidelines)
The field keyword¶
Use the field keyword instead of hand-written backing fields when a property needs simple validation, normalisation, or lazy logic in an accessor. Declare an explicit backing field only when it is used outside the accessors. This removes the field/property naming convention and the risk of code bypassing the accessor by writing to the field directly.
Before:
public class Sensor
{
private string _name = "";
public string Name
{
get => _name;
set => _name = value.Trim();
}
}
After:
(.NET Blog: Introducing C# 14)
Null-conditional assignment¶
Use null-conditional assignment (obj?.Property = value) to replace guarded if (obj is not null) nesting. The right-hand side is only evaluated when the receiver is non-null, so it is a faithful, flatter rewrite of the guard.
Before:
After:
(.NET Blog: Introducing C# 14)
Pattern matching¶
Use is null / is not null for all null checks, and a switch expression when each branch produces a value. Pattern is checks cannot be hijacked by overloaded ==/!= operators, and switch expressions with property, relational, and type patterns collapse chained if/else into one declarative table the compiler checks for exhaustiveness (CS8509 when an input is unhandled).
public static decimal DiscountFor(Customer customer) => customer switch
{
{ Tier: Tier.Gold, YearsActive: >= 5 } => 0.20m,
{ Tier: Tier.Gold } => 0.10m,
{ Tier: Tier.Silver } => 0.05m,
_ => 0m,
};
Order arms most-specific first, because they are matched top to bottom. Don't force patterns where a plain boolean expression reads better; a two-way if is not improved by becoming a switch. (Microsoft Learn: Pattern matching)
Collection expressions¶
Create and combine collections with collection expressions ([...]), not constructor-plus-initializer or LINQ Concat/ToArray chains. One syntax targets arrays, List<T>, spans, and immutable collections alike, the compiler picks the most efficient construction for the target type, and the spread element .. replaces allocating concatenation pipelines.
Before:
After:
The one cost: the target type must be explicit (List<int> ids = [...], not var ids = [...]). Accept that: the type is documentation. (Microsoft Learn: Collection expressions)
Nullable reference types¶
Enable nullable reference types in every project and promote nullable warnings to errors: <Nullable>enable</Nullable> plus <WarningsAsErrors>nullable</WarningsAsErrors> in Directory.Build.props, so annotations are enforced rather than advisory. Never start a new project without it; never use #nullable disable in new code. Reserve the null-forgiving operator ! for cases the flow analysis cannot see (e.g. values populated by a serializer or test setup), and treat every ! as a code smell to justify in review. In annotated code, ArgumentNullException.ThrowIfNull at public API boundaries is still correct, because annotations are compile-time only and do not protect against un-annotated or reflection-based callers. (Microsoft Learn: Nullable reference types)
Analyzers¶
Turn the built-in .NET analyzers up to latest-recommended, enforce code style in build, and add Meziantou.Analyzer. In Directory.Build.props:
<PropertyGroup>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
The built-in analyzers ship with the SDK and track it; latest-recommended keeps rules current across SDK updates without opting into the noisy all bucket. EnforceCodeStyleInBuild makes .editorconfig style rules (IDExxxx) build-time diagnostics instead of IDE-only suggestions, so CI and editors agree. Meziantou.Analyzer adds the correctness rules the SDK set misses (culture-sensitive string operations, CancellationToken forwarding, async pitfalls). Its author runs it beside several other analyzers, so add others where they cover rules you want; StyleCop is the one to skip when dotnet format already covers the formatting you care about. Fix or explicitly suppress with justification; never blanket-lower severity. (Microsoft Learn: Code analysis overview, Meziantou: The Roslyn analyzers I use, Meziantou: Meziantou.Analyzer)
Records¶
Keep records to the data they are constructed from: no derived state, no collection members. A record's generated members only behave the way the syntax suggests when every property is a constructor parameter held as-is. Two traps follow from that, and review should catch both:
- A property initialized from a constructor parameter goes stale under
with.withdoes not re-run the constructor: it lowers to roughlyvar copy = original.<Clone>$(); copy.Value = 3;, and the copy constructor duplicates every field before the property assignments run. Anything computed at construction therefore keeps its old value while the parameter it was computed from changes. (Skeet: Unexpected inconsistency in records, Skeet: Records and thewithoperator, redux)
// Wrong: Even is computed once, at construction
public sealed record Number(int Value)
{
public bool Even { get; } = (Value & 1) == 0;
}
var n3 = new Number(2) with { Value = 3 }; // Number { Value = 3, Even = True }
// Right: derive on read, so `with` cannot desynchronize it
public sealed record Number(int Value)
{
public bool Even => (Value & 1) == 0;
}
- A collection member breaks value equality. Generated
Equalscompares each member withEqualityComparer<T>.Default, and the immutable collections do not overrideEquals/GetHashCode.ImmutableList<T>and the rest compare by reference, and two records with identical contents are unequal. Hold a collection in a record only where reference equality is what you want (shared instances within one object graph); otherwise expose the collection outside the record, or accept that equality is identity and document it. (Skeet: Records and Collections)
Smaller opinions¶
- Prefer
Span<T>/ReadOnlySpan<T>parameters in new APIs: C# 14's implicit span conversions make them as ergonomic as arrays, without the allocation. Note the .NET 10 breaking change: span overloads now win overload resolution in more cases. (Microsoft Learn: What's new in C# 14, Microsoft Learn: Breaking changes in .NET 10) - Use
nameof(List<>)on unbound generics rather than hard-coded strings in diagnostics and exceptions. (Microsoft Learn: What's new in C# 14) - Use
StringComparison.Ordinalfor every machine-facing comparison andCultureInfo.InvariantCulturefor every machine-facing format or parse, and neverStringComparison.InvariantCulture, whose collation is not actually invariant. (globalization.md) - Clone records with
withexpressions, and never declare an instanceClone()method, which conflicts with the compiler-generated cloning; wrapwithin an extension method if a named method is wanted. (Meziantou: Adding a Clone method to a C# record)
Coming next (preview, not yet the opinion)¶
.NET 11 previews add union types and closed class hierarchies with compile-time exhaustiveness checking. Track them through Andrew Lock's series (Lock: .NET (OK, C#) finally gets union types, Lock: Closed class hierarchies); fold into opinions at .NET 11 GA.