Thursday, 18 December 2025

C# 14 Features You’ll Actually Use (With Real-Life Scenarios + Runnable Demo)

 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:

  • field keyword for auto-properties (validation without a manual backing field)
  • Null-conditional assignment (?. on the left-hand side)
  • nameof for 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:

  • DisplayName to be trimmed
  • DisplayName to 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 if block)

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 like Dictionary\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 (IsBlankWordCount)
  • Grouping in an extension(...) block makes it easy to scan: “these are all the members we’re adding to string

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

  • field removes 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

No comments:

Post a Comment