If you learn best by seeing code run, this post is for you.
In this article we’ll walk through a small, runnable console demo that showcases a handful of high-signal C# 14 features:
fieldkeyword for auto-properties (validation without a manual backing field)- Null-conditional assignment (
?.on the left-hand side) nameoffor unbound generic types (e.g.,nameof(List<>))- Extension members (
extension(...) { ... }) for properties + methods
At the end, we’ll compare why C# 14 feels nicer than earlier versions for these scenarios.
Prerequisites
- .NET SDK 10.x
1) field keyword: validation on auto-properties (without a manual backing field)
Real-life scenario
You’re building a customer profile page. You want:
DisplayNameto be trimmedDisplayNameto reject blank input
Historically, once you needed custom setter logic, you’d drop the auto-property and add a backing field.
Before (typical pre-C# 14 style)
private string _displayName = "";
public string DisplayName
{
get => _displayName;
set => _displayName = string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("DisplayName cannot be blank.", nameof(value))
: value.Trim();
}With C# 14 (field backed properties)
public required string DisplayName
{
get;
set => field = string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("DisplayName cannot be blank.", nameof(value))
: value.Trim();
}Why it’s better
- You keep the clarity of auto-properties
- You add logic without introducing another member (
_displayName) and all the naming/consistency overhead
2) Null-conditional assignment: “set this only if the object exists”
Real-life scenario
You look up a customer in a cache. Cache misses are normal.
- If the customer exists, set the loyalty tier
- If it doesn’t exist, do nothing (no exceptions, no
ifblock)
Before
if (customer is not null)
{
customer.LoyaltyTier = "Gold";
}With C# 14
customer?.LoyaltyTier = "Gold";Why it’s better
- Expresses intent directly: “maybe update”
- Reduces boilerplate and reduces the chance you forget the null check during refactors
3) nameof with unbound generics: logging the “shape” of a generic type
Real-life scenario
In telemetry/logging you often want to record which generic type definition you’re dealing with (the “shape”), not the exact T.
Example: you care that something is a Dictionary<,> in general — not whether it’s Dictionary<string, int> or Dictionary<Guid, decimal>.
Before (common workaround)
People often did one of these:
- log a specific closed generic:
nameof(Dictionary<string, int>)(not ideal) - use reflection:
typeof(Dictionary<,>).Name(often yields names likeDictionary\2`)
Console.WriteLine(nameof(List<>)); // "Compile error"
Console.WriteLine(typeof(List<>).Name); // "List`1"
Console.WriteLine(nameof(Dictionary<string, int>)); // "Dictionary"
Console.WriteLine(typeof(Dictionary<,>).Name); // "Dictionary`2"With C# 14
Console.WriteLine(nameof(List<>)); // "List"
Console.WriteLine(nameof(Dictionary<,>)); // "Dictionary"
Console.WriteLine(nameof(Dictionary<string, int>)); // "Dictionary"Why it’s better
- Readable, stable, and matches the simple names you expect (without backtick arity)
4) Extension members: extension “properties” + “methods” in one block
Real-life scenario
User input is messy. Across your app you frequently:
- detect blank strings
- normalize emails (
trim + lower) - count words for notes/descriptions
Before (classic extension methods only)
public static class StringExtensions
{
public static bool IsBlank(this string value) =>
string.IsNullOrWhiteSpace(value);
public static string NormalizedEmail(this string value) =>
value.Trim().ToLowerInvariant();
public static int WordCount(this string value) =>
value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}With C# 14 (extension members)
public static class StringExtensionsCSharp14
{
extension(string value)
{
public bool IsBlank => string.IsNullOrWhiteSpace(value);
public int WordCount =>
value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
public string NormalizedEmail() =>
value.Trim().ToLowerInvariant();
}
}What it feels like to use
var email = " alex@example.com ";
if (!email.IsBlank)
{
var normalized = email.NormalizedEmail();
Console.WriteLine(normalized);
}Why it’s better
- You can model “computed information” naturally as properties (
IsBlank,WordCount) - Grouping in an
extension(...)block makes it easy to scan: “these are all the members we’re adding tostring”
5) Simple lambda parameters with modifiers: ref / out without repeating types
Real-life scenario
- You parse input from a form/querystring (
out). - You perform an in-place update (
ref) — e.g., points, counters, totals.
Before C# 14, if you needed ref/out in a lambda you often had to repeat parameter types even though the compiler already knew them from the delegate signature.
Before (pre-C# 14 style: types are repeated)
delegate bool TryParse<T>(string text, out T result);
delegate void Doubler(ref int number);
TryParse<int> tryParseInt = (string text, out int result) =>
int.TryParse(text, out result);
Doubler doubleIt = (ref int number) => number *= 2;With C# 14 (simple parameters + modifiers)
TryParse<int> tryParseInt = (text, out result) =>
int.TryParse(text, out result);
Doubler doubleIt = (ref number) => number *= 2;Why it’s better
- Less noise: you don’t repeat types the compiler can infer
- Lambdas read closer to intent: “parse this, output result” / “modify this by ref”
How C# 14 is better than previous versions (in practical terms)
Here’s the “why you should care” summary:
Less boilerplate, more intent
fieldremoves the need for backing fields in many everyday validation scenarios- null-conditional assignment removes repetitive null-check blocks
More readable code in large codebases
nameof(List<>)is a small feature, but it makes logging/telemetry code cleaner and less error-prone- extension members let you write “domain language” that reads naturally (
email.IsBlank) instead of “utility-function soup”
Better refactoring ergonomics
- fewer manual backing fields and fewer guard
ifs means fewer places to accidentally break behavior during refactors
Reference
- Microsoft docs: What’s new in C# 14
No comments:
Post a Comment