C# 14 in Depth
Idiomatic, High-Performance .NET 10 for Working Engineers
Free — read online or download, no sign-up.
What you'll learn
- Master C# 14 / .NET 10: the `field` keyword, extension members, first-class spans, null-conditional assignment
- Write high-performance code with Span
, ref structs, stackalloc, SIMD, and allocation-free patterns - Understand async/await internals, ValueTask pooling, channels, cancellation, and the new Lock type
- Go deep on generics, generic math, source generators, function pointers, P/Invoke, and NativeAOT
- Design clean modern APIs, handle errors and resilience, and validate performance with BenchmarkDotNet
- Learn from 500+ idiomatic, runnable code examples — every snippet technically reviewed for correctness
Contents
- Part I — The Modern Type System
- Part II — Authoring Modern Types
- Part III — Generics, Math, and Abstraction
- Part IV — High-Performance C#
- Part V — Concurrency and Asynchrony
- Part VI — Metaprogramming and Interop
- Part VII — Libraries, APIs, and Quality
- Part VIII — Reference
Read the full book free below — or grab the PDF / EPUB above
About this book
This is a book for engineers who already write C# and want to go deeper — to understand not just the newest syntax, but the runtime mechanics underneath it. It covers the modern language end to end (through C# 14 on .NET 10) and pairs every feature with the performance, memory, and design reasoning an intermediate-to-expert developer actually needs.
It moves from the type system, records, pattern matching, and the newest authoring features (the field keyword, collection expressions, extension members) into the parts most books skip: Span<T> and ref safety, struct layout and the GC, SIMD, the async/await state machine, ValueTask pooling, channels, the new Lock type, source generators, function pointers, and NativeAOT — closing with API design, testing, and a C# 13/14 reference. Roughly 145,000 words and 500+ runnable code examples, every snippet technically reviewed for correctness and correct version attribution.
It is free — read the introduction below, then grab the full PDF or EPUB above.
What’s inside — 32 chapters across 8 parts
- Introduction: The Modern C# Landscape
Part I — The Modern Type System
-
- The Modern Type System: Values, References, and Everything Between
-
- Records, Immutability, and Value Semantics
-
- Pattern Matching, List Patterns, and Switch Expressions
-
- Nullable Reference Types and Flow Analysis
-
- Strings, Text, and Interpolation
Part II — Authoring Modern Types
-
- Primary Constructors and Collection Expressions
-
- The field Keyword and Modern Property Design
-
- Extension Members: Extension Everything (C# 14)
Part III — Generics, Math, and Abstraction
-
- Generics Deep Dive: Variance, Constraints, and Specialization
-
- Generic Math and Static Virtual/Abstract Interface Members
-
- Delegates, Lambdas, and Expression Trees
-
- Advanced LINQ and Query Composition
Part IV — High-Performance C#
-
- Span
, Memory , ref structs, and ref Safety
- Span
-
- stackalloc, Inline Arrays, and Fixed-Size Buffers
-
- SIMD and Vectorization: Vector
, Vector128/256/512
- SIMD and Vectorization: Vector
-
- Struct Layout, Allocations, and the Garbage Collector
Part V — Concurrency and Asynchrony
-
- async/await Internals and the State Machine
-
- ValueTask, Pooling, and Async Performance
-
- Async Streams, Channels, and Cancellation
-
- Threading, the new Lock Type, Interlocked, and Parallelism
Part VI — Metaprogramming and Interop
-
- Reflection, Metadata, and Source Generators
-
- Function Pointers, P/Invoke, and Native Interop
-
- NativeAOT, Trimming, and Fast Startup
Part VII — Libraries, APIs, and Quality
-
- Modern Collections: Frozen, Immutable, and Span-Friendly
-
- System.Text.Json in Depth
-
- Dates, Times, and Ambient State
-
- Error Handling, Validation, and Resilience
-
- Modern API and Library Design
-
- Testing, Benchmarking, and Performance Validation
Part VIII — Reference
-
- Capstone Reference: What’s New in C# 13 and C# 14
- Appendix. Tooling, Diagnostics, and the Modern Dev Environment
Read the introduction — free sample
C# stopped being a single-version language a long time ago. What you actually ship is a negotiation between an SDK, a target framework, and a language version — three knobs that fail in confusingly similar ways when they disagree. This chapter calibrates those knobs, sets the measurement discipline the rest of the book leans on, and establishes the version-attribution habit that keeps you out of the “why doesn’t this compile on the build server” trap.
Who this book is for
This is a book for people who already write C# for a living and want the part underneath. We assume you are fluent with generics, async/await, LINQ, the type system’s reference/value split, nullable reference types as a concept (even if your codebase hasn’t fully adopted them), and that you can read a stack trace without flinching. We do not re-teach for loops. We do re-teach things you think you know — like what a record actually lowers to, why Span<T> can’t live on the heap, and when a primary constructor captures a parameter into hidden state versus when it doesn’t.
The throughline is performance-first but readable. Those two goals are usually presented as a tradeoff; most of the time they aren’t. The fast path in modern .NET is frequently also the idiomatic one, because the BCL and the JIT have been co-designed for a decade to make the obvious code allocate less. Where they genuinely conflict — and they do, around Span<T>, ref struct, stack allocation, and aggressive inlining — we measure, show the IL or the JIT asm, and then make a deliberate call. We never reach for unsafe or a hand-rolled struct enumerator as a reflex. We reach for it with a benchmark in hand.
The release cadence: language, runtime, SDK
Three version numbers move on the same annual November cadence but mean different things. Conflating them is the single most common source of “feature not available” confusion, so internalize the mapping now:
| Released | .NET runtime | SDK band | C# language | Support |
|---|---|---|---|---|
| Nov 2023 | .NET 8 | 8.0.x | C# 12 | LTS |
| Nov 2024 | .NET 9 | 9.0.x | C# 13 | STS |
| Nov 2025 | .NET 10 | 10.0.x | C# 14 | LTS |
The runtime (net10.0) determines which BCL APIs exist and which JIT/GC you get. The language version (C# 14) determines which syntax the compiler accepts. These are independent. You can compile C# 14 syntax that targets net8.0 for many features — anything that’s pure syntactic sugar lowered by Roslyn (primary constructors, collection expressions, the field keyword) needs only a new enough compiler, not a new runtime. Other features need runtime or BCL support and will fail or silently degrade on older targets: first-class Span<T> conversions, params Span<T>, and System.Threading.Lock all want types and JIT behavior that older runtimes don’t have. The SDK is what bundles a particular Roslyn and a particular default language version; installing the .NET 10 SDK is necessary but, as we’ll see, not sufficient to get C# 14.
A compressed feature map for the three versions this book treats as “modern,” because we attribute every feature to its version throughout:
- C# 12 (.NET 8): primary constructors on all classes/structs; collection expressions
[a, b, ..spread];ref readonlyparameters; default lambda parameter values;usingaliases for any type (tuples, arrays, generics); inline arrays via[InlineArray]. - C# 13 (.NET 9):
paramscollections (params Span<T>,params IEnumerable<T>, any collection type); the dedicatedSystem.Threading.Locktype and itslockpattern; the\eescape for ESC (0x1B);reflocals andunsafeblocks permitted inside iterators andasyncmethods; theallows ref structanti-constraint andref structinterface implementation; implicit index (^) in object initializers;[OverloadResolutionPriority]; partial properties and indexers. - C# 14 (.NET 10): the
fieldcontextual keyword (compiler-synthesized backing field without an explicit one); extension members viaextensionblocks (extension properties, static extension members, extension operators); null-conditional assignment (x?.Prop = v,x?[i] = v);nameofwith unbound generics (nameof(List<>)); first-class implicitSpan<T>/ReadOnlySpan<T>conversions; partial constructors and partial events; user-defined compound assignment operators; modifiers (ref/out/in/scoped) on implicitly typed lambda parameters.
How version-attribution callouts work in this book
Every time we introduce syntax, we name the version that shipped it, in prose, like this: “Introduced in C# 14, the field keyword…”. We do this because intermediate-to-expert readers don’t read a book front-to-back; they jump to the chapter on collections or async and copy a snippet. Without the attribution, that snippet becomes a support ticket when it lands in a net8.0 library with <LangVersion>8</LangVersion>. Treat the version tag as part of the API contract of the snippet — as load-bearing as the type signature.
Feature gating in the csproj: the only screen that matters
Here is a complete, modern project file. Read it as the canonical setup for every sample in this book.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<!-- Nullable reference types on, warnings-as-errors for null-safety -->
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- AOT-ready posture: surfaces trim/AOT incompatibilities at build time -->
<PublishAot>true</PublishAot>
<IsTrimmable>true</IsTrimmable>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Make the JIT's choices observable for the disassembly chapters -->
<ServerGarbageCollection>false</ServerGarbageCollection>
</PropertyGroup>
</Project>
Two of these lines decide whether your C# 14 code compiles at all, and they are the first place to look when a feature “isn’t available”:
<TargetFramework>net10.0</TargetFramework> selects the runtime/BCL and, critically, sets the default language version. With net10.0, the default LangVersion is already 14. So why pin it explicitly? Because the default is a function of the TFM, and the moment someone multi-targets (<TargetFrameworks>net8.0;net10.0</TargetFrameworks>) the net8.0 leg silently drops to C# 12. Pinning <LangVersion>14</LangVersion> forces C# 14 syntax across all target legs — which then fails fast on net8.0 for any feature that needs runtime support, which is exactly the signal you want at build time rather than at runtime.
<Nullable>enable</Nullable> (a project-wide knob since .NET 5, but the default in every template since .NET 6) turns nullable reference type analysis on. We keep it on everywhere and pair it with <TreatWarningsAsErrors>true</TreatWarningsAsErrors> so a CS8602 dereference-of-a-possibly-null-reference is a build break, not a warning you’ll learn to ignore.
<PublishAot>true</PublishAot> doesn’t change dotnet build, but it makes dotnet publish use Native AOT and — more usefully day-to-day — it makes the analyzers flag trim-unsafe and reflection-heavy patterns as you write them. Adopting an AOT-ready posture early is cheap; retrofitting it onto a reflection-soaked codebase is not.
Pitfall: the SDK is installed but the language is pinned old
The most expensive five minutes you’ll lose this year: you dotnet --version, see 10.0.100, write an extension block, and the compiler says “Feature ‘extension members’ is not available in C# 12. Please use language version 14.0 or greater.” The SDK is fine. The project pinned <LangVersion>12</LangVersion> (or inherited it from a multi-target leg, or a Directory.Build.props set it org-wide). The compiler error text names the version — read it. The fix is in the csproj, never in your SDK install.
Pitfall: confusing runtime version with language version
The mirror-image failure. You target net8.0, set <LangVersion>14</LangVersion>, and a params ReadOnlySpan<T> overload either won’t resolve to the span form or behaves like the old params T[]. That’s not a language problem — C# 14 the syntax is available — it’s a runtime/BCL problem: the span-based overloads and the JIT support they rely on live in .NET 9+. “Language version” answers can the compiler parse this?; “target framework” answers does the runtime have the type and codegen to back it?. When a feature half-works, ask which of those two you’re actually missing before you touch anything.
A minimal Program.cs that sets the tone
Top-level statements (C# 9) and file-scoped namespaces (C# 10) are old enough that we treat them as the baseline, not a feature. Collection expressions (C# 12) replace the new[] {…} / new List<int> {…} ceremony with a target-typed […] that the compiler lowers to the cheapest construction for the destination type — a literal array for T[]; a List<T> built in place via CollectionsMarshal.SetCount + AsSpan (one allocation, no resize churn) when the length is known; the builder for an ImmutableArray<T>; and, for a Span<T>/ReadOnlySpan<T> target, a stackalloc or inline-array buffer with no heap allocation at all.
// Program.cs — top-level statements, no Main, no enclosing class.
using System.Globalization;
// Collection expression (C# 12). Target-typed to int[]: lowered to a literal array.
int[] readings = [21, 19, 23, 18, 25, 22];
// Spread (..) composes collections without an intermediate allocation per element.
int[] withSentinels = [0, ..readings, 0];
double mean = readings.Average();
int peak = readings.Max();
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$"n={readings.Length} mean={mean:F1} peak={peak} padded={withSentinels.Length}"));
// n=6 mean=21.3 peak=25 padded=8
There is no class here, no static void Main. The compiler synthesizes a Program class with a Main for you; args is in scope implicitly if you reference it. This particular string.Create overload — Create(IFormatProvider?, ref DefaultInterpolatedStringHandler) — is chosen because it lets you pass an explicit CultureInfo.InvariantCulture and route through the interpolated-string-handler machinery (C# 10) in one call. The handler’s AppendFormatted<T>(T value, …) is generic, so each int/double is formatted without the object-boxing that the old string.Format(IFormatProvider, string, object?[]) path incurs for value-type arguments — a small thing that matters in hot paths and that we’ll return to.
Global usings and aliasing any type
<ImplicitUsings>enable</ImplicitUsings> injects a curated set of global using directives (System, System.Linq, System.Collections.Generic, …). You add your own in one file and they apply solution-wide. Introduced in C# 12, the using alias can now name any type — including tuples, arrays, and constructed generics — not just named types. This is the cleanest way to give a structurally-typed value a name without paying for a wrapper struct:
// GlobalUsings.cs — one file, applies to the whole project.
global using System.Diagnostics;
global using System.Runtime.CompilerServices;
// C# 12: alias an arbitrary type. This names a tuple shape; it is NOT a new type.
global using GeoPoint = (double Lat, double Lon);
// C# 12: alias a constructed generic, too.
global using StringMap = System.Collections.Generic.Dictionary<string, string>;
// Usage anywhere in the project — GeoPoint is structurally a (double, double).
GeoPoint prague = (50.0755, 14.4378);
GeoPoint brno = ParseCity("Brno");
static GeoPoint ParseCity(string _) => (49.1951, 16.6068);
// Because it's an alias, not a wrapper, this assignment is legal across the seam:
(double lat, double lon) raw = prague; // no conversion, same underlying ValueTuple
The tradeoff is real and worth stating: an alias is not a nominal type. GeoPoint and (double, double) are interchangeable, which means you get zero type-safety against mixing up a GeoPoint and a (double Width, double Height). When you want the compiler to stop you from passing latitude where you meant width, reach for a readonly record struct, not an alias. The alias buys readability, not safety.
The measurement template: BenchmarkDotNet
Every performance claim in this book is backed by a benchmark, and they all follow one shape. BenchmarkDotNet (BenchmarkDotNet NuGet, run from a Release build) handles warmup, statistical iteration, and — via [MemoryDiagnoser] — allocation accounting. This is the harness you’ll see, verbatim in spirit, for the rest of the book:
// Benchmarks.cs — the book's measurement template.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<SumBench>();
[MemoryDiagnoser] // reports Allocated bytes/op + GC counts
public class SumBench
{
private readonly int[] _data = [.. Enumerable.Range(0, 1024)];
[Benchmark(Baseline = true)]
public long Linq() => _data.Sum(); // delegate + enumerator overhead
[Benchmark]
public long Loop() // zero-allocation, JIT-friendly
{
long sum = 0;
foreach (int x in _data) sum += x;
return sum;
}
}
Run it with dotnet run -c Release. Two columns earn their keep: Ratio (relative to the Baseline = true method) and Allocated (bytes per operation). When Allocated reads -, the method allocated nothing — the bar we hold the hot path to. [MemoryDiagnoser] works by reading GC.GetAllocatedBytesForCurrentThread() and the per-generation collection counts around the measured runs, so it attributes managed allocations to each benchmark essentially for free; it’s the cheapest insurance against accidental allocation regressions, which is why it’s on every benchmark in this book. Never benchmark a Debug build: without optimizations the JIT skips inlining, omits enregistration, and inserts debug-only bookkeeping, so the numbers become fiction. BenchmarkDotNet refuses to run an assembly built in Debug and will tell you so.
Conventions: SharpLab, IL, and the disassembly mindset
We inspect lowering constantly. Three tools recur:
- SharpLab (sharplab.io) — paste C#, see the Roslyn-lowered C#, the IL, or the JIT asm, for a chosen language version. This is how we prove that a primary constructor captured a parameter into a field, or that a collection expression became a
stackalloc. When a chapter says “this lowers to,” that claim was checked in SharpLab. - IL inspection —
ildasm, ILSpy, ordotnet-ildasm. IL tells you what the compiler decided; asm tells you what the JIT decided. We read IL to settle questions about boxing, defensive copies ofreadonly structs, and call-vs-callvirt dispatch. - The disassembly mindset — when behavior surprises you, drop a level. A
record’sEqualsis generated IL you can read. ASpan<T>indexer is a couple of asm instructions you can count. “Surprising” almost always means “I had the wrong mental model of the lowering,” and the lowering is right there.
You don’t need to memorize IL opcodes. You need the reflex to check, and a rough sense of what’s expensive: a newobj in a loop is a heap allocation; a box is an allocation plus a copy; a callvirt on a sealed type is a missed devirtualization opportunity.
The running example domains
Rather than disconnected toy snippets, the book reuses a few small domains so you build intuition across chapters:
- Telemetry ingestion — fixed-size sensor readings flowing through
Span<T>pipelines. The vehicle for stack allocation,ref struct, first-class spans, and zero-allocation parsing. - An order/pricing domain —
recordandreadonly record structvalue objects (Money,Sku,OrderLine). The vehicle for value semantics,withexpressions, pattern matching, and primary constructors. - A tiny in-memory event log — concurrent appends and reads. The vehicle for the C# 13
Locktype,Channel<T>, and the async/threading chapters.
Worked example: the same idea, three C# eras
To tie the chapter together — and to make the version-attribution habit concrete — here is one small value object evolving across the three modern language versions. It computes a discounted line total and exposes a validated, mutable label.
using System.Globalization;
// readonly record struct (C# 10): value semantics, no heap allocation, no defensive copies.
// The positional parameters are a record's primary constructor — records (incl. record
// structs) have had these since C# 9/10; C# 12 only extended primary constructors to plain
// (non-record) classes and structs, as on OrderLine below.
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money operator *(Money m, decimal factor) => m with { Amount = m.Amount * factor };
public override string ToString() =>
string.Create(CultureInfo.InvariantCulture, $"{Amount:0.00} {Currency}");
}
public sealed class OrderLine(Sku sku, int quantity, Money unitPrice)
{
// C# 14: the `field` keyword. A validated property with NO hand-written backing field.
// Replaces the old private-field-plus-two-accessors boilerplate.
public string Label
{
get;
set => field = string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Label required", nameof(value))
: value.Trim();
} = sku.Code; // primary-ctor param (C# 12) in the initializer. NB: a property
// initializer writes the backing field DIRECTLY — it does NOT run
// the validating setter, so sku.Code lands untrimmed/unvalidated.
public int Quantity { get; } = quantity > 0
? quantity
: throw new ArgumentOutOfRangeException(nameof(quantity));
// Ordinary optional parameter (a C# 1-era feature) — NOT to be confused with the
// C# 12 "default lambda parameter values"; this is a method, not a lambda.
public Money Total(decimal discount = 0m) =>
unitPrice * Quantity * (1 - discount);
}
public readonly record struct Sku(string Code);
// Extension members (C# 14): an `extension` block, not the old `this`-parameter syntax.
// Static class, non-generic, non-nested — the compiler requirement for extension blocks.
public static class OrderLineExtensions
{
extension(OrderLine line)
{
// Extension PROPERTY (C# 14) — impossible before; extension methods couldn't be properties.
public bool IsBulk => line.Quantity >= 100;
// Extension method, now living in the same receiver block.
public Money DiscountedTotal() => line.Total(line.IsBulk ? 0.15m : 0m);
}
}
// Driving code.
var line = new OrderLine(new Sku("WIDGET-9"), quantity: 120, unitPrice: new Money(4.50m, "EUR"))
{
Label = " Premium Widget " // trimmed + validated by the `field` setter
};
Console.WriteLine(line.Label); // Premium Widget
Console.WriteLine(line.IsBulk); // True (extension property, C# 14)
Console.WriteLine(line.DiscountedTotal()); // 459.00 EUR
// Null-conditional assignment (C# 14): assign through a possibly-null reference, no if-guard.
OrderLine? maybe = Lookup("WIDGET-9");
maybe?.Label = "Renamed"; // RHS evaluated only if maybe is non-null
static OrderLine? Lookup(string _) => null; // returns null here → assignment is a no-op
What this demonstrates, with versions attributed:
- The
readonly record struct Money(C# 10) gives value equality and immutability with zero heap allocation —Moneylives on the stack or inline in its container. Marking itreadonlylets the compiler prove no member mutatesthis, which eliminates the defensive copies Roslyn would otherwise emit whenever you call an instance member through a read-only reference to the struct — areadonlyfield, aninparameter, or astatic readonly. Those copies are an IL-level artifact (ldobj/stobjor a stack temporary), not a JIT decision; verify in SharpLab that withreadonlythe copy dance is simply absent. - The
fieldkeyword (C# 14) collapses the validated-property boilerplate. Before C# 14 theLabelproperty needed an explicitprivate string _label;and both accessors referencing it. The lowering is identical — Roslyn still synthesizes a backing field — so there’s no runtime cost, only less code to get wrong. Watch the one gotcha the docs call out: becausefieldis now a contextual keyword, if your type already has a member literally namedfield, then inside a property accessor body an unqualifiedfieldbinds to the synthesized backing field, not your member — a silent binding change from C# 13. The compiler raises a warning at language version 14, and the fix is to escape the identifier as@field(or qualify an instance member asthis.field). - The
extension(OrderLine line)block (C# 14) letsIsBulkbe a property, which the classicstatic bool IsBulk(this OrderLine line)syntax could never express — extension methods could only ever be methods. The lowering is still a static method; the property is a compiler-level affordance, so there’s no dispatch cost beyond a normal static call (and the JIT inlines trivial ones). - Null-conditional assignment (C# 14) replaces the
if (maybe is not null) maybe.Label = …guard withmaybe?.Label = …, and — this is the part people miss — the right-hand side is not evaluated when the receiver is null. If the RHS were an expensiveGetCurrentOrder()call, you’d skip it for free. Increment/decrement (maybe?.Count++) remain disallowed; assignment and compound assignment (+=,-=) are in.
When not to reach for these
Modern syntax is not free of judgment. A few standing rules we’ll reinforce throughout:
- A
readonly record structis the wrong choice once it grows past roughly the size of a couple of pointers and gets passed around by value a lot — the copies overwhelm the no-allocation win. Measure with the template above; ifMoneywere sixteen fields you’d want aclassorin-passing. - The
fieldkeyword is great for validated or lazy properties. Don’t use it to dress up a plain auto-property —public string Name { get; set; }is already minimal, and adding afieldbody just adds surface area. - Extension everything invites putting domain logic in static classes far from the type. Extension members are for augmenting types you don’t own (BCL types, generated code) and for genuinely cross-cutting helpers — not as a substitute for instance methods on types you control.
- Null-conditional assignment quietly turns “do nothing when null” into the default. That’s occasionally a bug waiting to happen: if a null receiver should be an error, the explicit
if/throw is clearer than a silent no-op.
With the project file pinned, the benchmark harness ready, and the disassembly tools at hand, you’re equipped to read the rest of this book the way it’s written — every claim measurable, every feature dated, every fast path checked against the IL.
That’s the introduction. The remaining 31 chapters — 31 more, ~700 pages — are in the free PDF and EPUB above. Part of Clarqo Press.