Advanced C# A 06 11 topics

Pattern Matching and Records

Patterns take data apart. Records put it together. Both moved C# toward a data-first style, where small immutable values flow through switch expressions instead of long if-else chains.

This page assumes you know classes, structs and the old switch statement. It walks through every pattern kind from C# 7 to C# 11. Then it covers records, with, deconstruction and tuples. It ends with the C# 11 and 12 features that change how you declare types: required, primary constructors and collection expressions.

Contents

  1. Type, constant, relational and logical patterns
  2. Property and extended property patterns
  3. Positional patterns
  4. List patterns and slices
  5. Switch expressions and exhaustiveness
  6. Records and value equality
  7. with-expressions
  8. Deconstruction and tuples
  9. init and required members
  10. Primary constructors and capture pitfalls
  11. Collection expressions and spread
  12. Recap

Several samples share a small shape hierarchy. It is defined once here and reused by later blocks.

/// <summary>Base type for the shape samples. Abstract so every shape is a subtype.</summary>
/// <example>Shape s = new Circle(1.0);</example>
public abstract record Shape;

/// <summary>A circle with a radius.</summary>
/// <example>new Circle(2.0)</example>
public sealed record Circle(double Radius) : Shape;

/// <summary>An axis-aligned rectangle.</summary>
/// <example>new Rect(2.0, 3.0)</example>
public sealed record Rect(double Width, double Height) : Shape;

/// <summary>A triangle given by base and height.</summary>
/// <example>new Triangle(4.0, 2.0)</example>
public sealed record Triangle(double Base, double Height) : Shape;

1. Type, constant, relational and logical patterns

type and constant: C# 7relational and logical: C# 9

What it is

Why and when

Use them to replace chains of casts and comparisons. x is >= 'a' and <= 'z' reads like the rule it encodes. It also evaluates x only once. is null and is not null ignore any overloaded ==, so they always mean a real null check.

Sample

public static class BasicPatterns
{
    /// <summary>Describes any object using type and constant patterns.</summary>
    /// <param name="value">Anything, including null.</param>
    /// <returns>A short description.</returns>
    /// <example>BasicPatterns.Describe(42) returns "int 42"</example>
    public static string Describe(object? value) => value switch
    {
        null => "null",
        0 => "zero",                         // constant pattern on a boxed int
        int n => $"int {n}",                 // type pattern binds n
        string { Length: 0 } => "empty string",
        string s => $"string {s}",
        _ => value.GetType().Name,
    };

    /// <summary>Classifies a char with relational and logical patterns.</summary>
    /// <param name="c">Any char.</param>
    /// <returns>"lower", "upper", "digit" or "other".</returns>
    /// <example>BasicPatterns.Classify('Q') returns "upper"</example>
    public static string Classify(char c) => c switch
    {
        >= 'a' and <= 'z' => "lower",
        >= 'A' and <= 'Z' => "upper",
        >= '0' and <= '9' => "digit",
        _ => "other",
    };

    /// <summary>Maps an age to a ticket band.</summary>
    /// <param name="age">Age in years.</param>
    /// <returns>The band name.</returns>
    /// <example>BasicPatterns.Ticket(70) returns "senior"</example>
    public static string Ticket(int age) => age switch
    {
        < 0 => throw new ArgumentOutOfRangeException(nameof(age)),   // below 0 is invalid
        < 3 => "free",            // under 3 travel free
        < 18 => "child",          // 18 is the adult boundary
        >= 65 => "senior",        // 65 is the senior boundary
        _ => "adult",
    };

    /// <summary>True for weekend days, using an or pattern on an enum.</summary>
    /// <param name="d">A day.</param>
    /// <returns>True on Saturday and Sunday.</returns>
    /// <example>BasicPatterns.IsWeekend(DayOfWeek.Sunday) returns true</example>
    public static bool IsWeekend(DayOfWeek d) => d is DayOfWeek.Saturday or DayOfWeek.Sunday;

    /// <summary>Shows "is not" with a type pattern for early return.</summary>
    /// <param name="value">Anything.</param>
    /// <returns>The string's length, or -1 if value is not a string.</returns>
    /// <example>BasicPatterns.LengthOrMinusOne("abc") returns 3</example>
    public static int LengthOrMinusOne(object? value)
    {
        // -1 is the "not a string" sentinel.
        if (value is not string s) return -1;
        // s is definitely assigned here because the negated pattern failed.
        return s.Length;
    }
}

Pitfalls

Interview questions

Q. Why prefer x is null over x == null?
A. A type can overload == and make x == null lie. is null always tests the reference itself. It also works on unconstrained generics.
Q. What does c is >= 'a' and <= 'z' compile to?
A. Two comparisons on one read of c, the same as c >= 'a' && c <= 'z'. No allocation and no extra cost.

2. Property and extended property patterns

property: C# 8extended: C# 10

What it is

A property pattern { Name: pattern, ... } checks members of an object. The object must be non-null for it to match, so { } means “not null”. Extended property patterns (C# 10) let you reach into nested members with dots: { Address.City: "Oslo" } instead of { Address: { City: "Oslo" } }.

Why and when

Use them for rules over object shapes: validation, routing, pricing. Several conditions on one object sit in one readable line. Null checks along the path come for free.

Sample

/// <summary>A postal address.</summary>
/// <example>new Address("Oslo", "NO")</example>
public sealed record Address(string City, string Country);

/// <summary>A customer with an optional address.</summary>
/// <example>new Customer("ann", 5, new Address("Oslo", "NO"))</example>
public sealed record Customer(string Name, int Orders, Address? Home);

public static class PropertyPatterns
{
    /// <summary>Picks a shipping rate from nested customer data.</summary>
    /// <param name="c">The customer, possibly null.</param>
    /// <returns>The rate name.</returns>
    /// <example>Shipping(new Customer("a", 0, new Address("Oslo", "NO"))) returns "domestic"</example>
    public static string Shipping(Customer? c) => c switch
    {
        null => "no customer",
        { Home: null } => "no address",
        { Orders: >= 10 } => "free",                     // 10 orders earns free shipping
        { Home.Country: "NO" } => "domestic",            // extended property pattern
        { Home: { Country: "SE" or "DK" } } => "nordic", // the C# 8 nested form
        _ => "international",
    };

    /// <summary>Uses an empty property pattern as a non-null test that also binds.</summary>
    /// <param name="c">The customer, possibly null.</param>
    /// <returns>The city, or "?" when any link is null.</returns>
    /// <example>CityOrUnknown(null) returns "?"</example>
    public static string CityOrUnknown(Customer? c) =>
        c is { Home: { } home } ? home.City : "?";
}

Pitfalls

Interview questions

Q. What does obj is { } mean?
A. “obj is not null.” An empty property pattern matches any non-null value. With a designation, obj is { } x, it also binds the non-null value.

3. Positional patterns

C# 8

What it is

A positional pattern (p1, p2, ...) matches by calling Deconstruct. Records with a primary constructor get one for free. Tuples match positionally too. So (a, b) switch { (0, 0) => ... } handles several inputs at once.

Why and when

Use it for small state tables: two or three inputs mapped to a result. Rock-paper-scissors, traffic lights, state machines and point quadrants all fit. Each arm is one row of the table.

Sample

/// <summary>A 2D point with integer coordinates.</summary>
/// <example>new Point(1, -2)</example>
public readonly record struct Point(int X, int Y);

/// <summary>Light states for the state-machine sample.</summary>
public enum Light { Red, Green, Yellow }

public static class PositionalPatterns
{
    /// <summary>Names the quadrant of a point by deconstructing it.</summary>
    /// <param name="p">The point.</param>
    /// <returns>"origin", "axis", or "Q1" to "Q4".</returns>
    /// <example>Quadrant(new Point(-1, 2)) returns "Q2"</example>
    public static string Quadrant(Point p) => p switch
    {
        (0, 0) => "origin",
        (0, _) or (_, 0) => "axis",         // 0 on one coordinate means on an axis
        (> 0, > 0) => "Q1",
        (< 0, > 0) => "Q2",
        (< 0, < 0) => "Q3",
        _ => "Q4",
    };

    /// <summary>A traffic light transition table on a tuple of state and event.</summary>
    /// <param name="state">Current light.</param>
    /// <param name="timerFired">True when the phase timer ran out.</param>
    /// <returns>The next light.</returns>
    /// <example>Next(Light.Green, true) returns Light.Yellow</example>
    public static Light Next(Light state, bool timerFired) => (state, timerFired) switch
    {
        (_, false) => state,                 // no timer, no change
        (Light.Red, true) => Light.Green,
        (Light.Green, true) => Light.Yellow,
        (Light.Yellow, true) => Light.Red,
        _ => throw new ArgumentOutOfRangeException(nameof(state)),
    };
}

Pitfalls

Interview questions

Q. What does a type need to support positional patterns?
A. An accessible Deconstruct method with out parameters, as an instance or extension method. Records and tuples have one already.

4. List patterns and slices

C# 11

What it is

A list pattern [p1, p2, ...] matches arrays, lists, spans and any type with a Length or Count plus an indexer. Each element is matched by a pattern. A slice .. matches zero or more elements, at most once per list pattern. .. var rest captures the slice if the type supports ranges.

int[] xs = [1, 5, 7, 9] matched by [1, .. var middle, 9] 1 5 7 9 xs[0] xs[1..^1] xs[^1] is 1 middle = [5, 7] is 9 Length check first: Length >= 2 is required Fixed elements index from the front or the back. The slice takes whatever is left.
Figure A6.1 — A list pattern checks the length, matches fixed positions from both ends, and hands the middle to the slice.

Reading the figure. Green cells matched a constant pattern. Amber cells fell into the slice and were captured as middle. The grey labels show the index or range the compiler uses for each part. Notice that the last element is reached with ^1, so the pattern works for any length of 2 or more.

Why and when

Use list patterns for command parsing, protocol headers, and recursive functions over sequences. It is also good for “first and rest” style code, which used to need index checks and bounds guards.

Sample

public static class ListPatterns
{
    /// <summary>Describes the shape of an int array with list patterns.</summary>
    /// <param name="xs">The array.</param>
    /// <returns>A description.</returns>
    /// <example>Shape([1, 5, 7, 9]) returns "1 .. 9 with 2 inside"</example>
    public static string Shape(int[] xs) => xs switch
    {
        [] => "empty",
        [var only] => $"one: {only}",
        [var a, var b] => $"pair: {a},{b}",
        [1, .. var middle, 9] => $"1 .. 9 with {middle.Length} inside",   // 1 and 9 are sample bounds
        [_, _, ..] => $"starts {xs[0]},{xs[1]}",
    };

    /// <summary>Parses a tiny command line with list patterns over strings.</summary>
    /// <param name="args">Words of the command.</param>
    /// <returns>What the command would do.</returns>
    /// <example>Command(["copy", "a", "b"]) returns "copy a -> b"</example>
    public static string Command(string[] args) => args switch
    {
        ["help"] or [] => "usage",
        ["copy", var from, var to] => $"copy {from} -> {to}",
        ["delete", .. var files] when files.Length > 0 => $"delete {files.Length} files",
        [var verb, ..] => $"unknown {verb}",
    };

    /// <summary>Sums a span recursively with a head and a rest slice.</summary>
    /// <param name="xs">Numbers to add.</param>
    /// <returns>The sum. 0 for an empty span.</returns>
    /// <example>Sum([1, 2, 3]) returns 6</example>
    public static int Sum(ReadOnlySpan<int> xs) => xs switch
    {
        [] => 0,                                  // 0 is the empty sum
        [var head, .. var rest] => head + Sum(rest),   // slicing a span copies nothing
    };
}

Pitfalls

Interview questions

Q. Which types work with list patterns?
A. Any type that is countable (a Length or Count property) and indexable (an int or Index indexer). Slices with a capture also need a Range indexer or a Slice method. Arrays, List<T>, strings and spans qualify.

5. Switch expressions and exhaustiveness

C# 8when guards: C# 7

What it is

A switch expression x switch { pattern => value, ... } produces a value. Arms are tried top to bottom. The compiler checks exhaustiveness. If some input could match no arm, it warns with CS8509. At run time such an input throws SwitchExpressionException. A when clause adds a condition that patterns cannot express.

Why and when

Use it whenever a value depends on the shape of an input. Compared to a switch statement there is no break, no fall-through and no forgotten assignment. The exhaustiveness warning turns into a to-do list when you add a new case.

Sample

public static class SwitchExpressions
{
    /// <summary>Area of any shape. Exhaustive over the known subtypes.</summary>
    /// <param name="s">The shape.</param>
    /// <returns>Its area.</returns>
    /// <example>Area(new Rect(2, 3)) returns 6</example>
    public static double Area(Shape s) => s switch
    {
        Circle c => Math.PI * c.Radius * c.Radius,
        Rect(var w, var h) => w * h,
        Triangle t => t.Base * t.Height / 2,      // / 2: a triangle is half its box
        // Without this arm the compiler warns (CS8509): Shape is not sealed.
        _ => throw new ArgumentException($"unknown shape {s.GetType().Name}", nameof(s)),
    };

    /// <summary>Labels a shape, using when for a rule patterns cannot state.</summary>
    /// <param name="s">The shape.</param>
    /// <returns>A label.</returns>
    /// <example>Label(new Rect(2, 2)) returns "square"</example>
    public static string Label(Shape s) => s switch
    {
        Rect r when r.Width == r.Height => "square",   // compares two members
        Rect => "rectangle",
        Circle { Radius: 0 } => "point",
        Circle => "circle",
        _ => "other",
    };

    /// <summary>An exhaustive switch on bool tuples needs no discard arm.</summary>
    /// <param name="a">First input.</param>
    /// <param name="b">Second input.</param>
    /// <returns>a XOR b.</returns>
    /// <example>Xor(true, false) returns true</example>
    public static bool Xor(bool a, bool b) => (a, b) switch
    {
        (true, true) => false,
        (true, false) => true,
        (false, true) => true,
        (false, false) => false,
    };
}

/// <summary>A shape the switch above does not know, to show the runtime failure.</summary>
/// <example>new Hexagon(1.0)</example>
public sealed record Hexagon(double Side) : Shape;

Pitfalls

Interview questions

Q. What happens if no arm of a switch expression matches?
A. It throws SwitchExpressionException at run time. The compiler warns ahead of time (CS8509) if it can find an input that no arm covers.
Q. Switch statement or switch expression?
A. Expression when each case produces a value. Statement when cases run several side-effecting steps. The expression is shorter, has no fall-through, and gets exhaustiveness checks.

6. Records and value equality

record class: C# 9record struct: C# 10

What it is

A record is a class whose equality is based on its values, not its reference. The compiler generates Equals, GetHashCode, ==, !=, a readable ToString, a Deconstruct and copy support for with. Positional records (record P(int X, int Y)) also get init-only properties and a constructor. record struct (C# 10) does the same for value types. Its properties are mutable unless you write readonly record struct.

two objects, same values: Name = "pen", Price = 3 class object at #1 pen, 3 object at #2 pen, 3 a == b is false compares addresses #1 and #2 record object at #3 pen, 3 object at #4 pen, 3 a == b is true compares type, Name, Price ReferenceEquals(a, b) is still false for the records. They are two objects.
Figure A6.2 — A class compares where objects live, while a record compares what they contain.

Reading the figure. Each box is a separate heap object with an address. Red is reference equality, which fails because the addresses differ. Green is value equality, which checks the runtime type and then every field. Notice the last line. Records change what == means, not how many objects exist.

Why and when

Sample

/// <summary>A plain class for comparison. Equality is by reference.</summary>
/// <example>new PlainItem("pen", 3) == new PlainItem("pen", 3) is false</example>
public sealed class PlainItem(string name, int price)
{
    public string Name { get; } = name;
    public int Price { get; } = price;
}

/// <summary>The same data as a positional record. Equality is by value.</summary>
/// <example>new Item("pen", 3) == new Item("pen", 3) is true</example>
public sealed record Item(string Name, int Price);

/// <summary>An amount of money as a small immutable value type.</summary>
/// <example>new Money(5, "EUR").ToString() returns "Money { Amount = 5, Currency = EUR }"</example>
public readonly record struct Money(decimal Amount, string Currency);

/// <summary>A record holding a list. Equality compares the list by reference.</summary>
/// <example>Two Baskets with equal but separate lists are not equal</example>
public sealed record Basket(string Owner, List<string> Items);

public static class RecordDemo
{
    /// <summary>Compares class and record equality for equal data.</summary>
    /// <returns>The results of == for each, and of ReferenceEquals for the records.</returns>
    /// <example>RecordDemo.Equality() returns "class=False record=True sameObject=False"</example>
    public static string Equality()
    {
        // 3 is a sample price. Each pair holds equal data in two separate objects.
        bool classEq = new PlainItem("pen", 3) == new PlainItem("pen", 3);
        var a = new Item("pen", 3);
        var b = new Item("pen", 3);
        return $"class={classEq} record={a == b} sameObject={ReferenceEquals(a, b)}";
    }

    /// <summary>Uses records as dictionary keys, which works because of value hashing.</summary>
    /// <returns>The count stored under an equal but separate key.</returns>
    /// <example>RecordDemo.AsKey() returns 2</example>
    public static int AsKey()
    {
        var counts = new Dictionary<Money, int>();
        // Two equal Money values land in the same slot. 5 is a sample amount.
        counts[new Money(5, "EUR")] = 1;
        counts[new Money(5, "EUR")] += 1;
        return counts[new Money(5, "EUR")];
    }

    /// <summary>Shows that value equality is shallow: lists compare by reference.</summary>
    /// <returns>Whether two baskets with equal lists are equal.</returns>
    /// <example>RecordDemo.ShallowEquality() returns false</example>
    public static bool ShallowEquality() =>
        new Basket("ann", ["pen"]) == new Basket("ann", ["pen"]);

    /// <summary>Shows the generated ToString.</summary>
    /// <returns>The record's text form.</returns>
    /// <example>RecordDemo.Text() returns "Item { Name = pen, Price = 3 }"</example>
    public static string Text() => new Item("pen", 3).ToString();
}

Pitfalls

Interview questions

Q. What does the compiler generate for a record?
A. Value-based Equals and GetHashCode, == and !=, IEquatable<T>, a ToString that prints members, a copy constructor and clone method for with, and, for positional records, init properties plus Deconstruct.
Q. record class or record struct?
A. record class by default, especially with inheritance or many fields. readonly record struct for small values created in large numbers, where avoiding allocation matters.

7. with-expressions

records: C# 9structs and anonymous types: C# 10

What it is

x with { P = v } makes a copy of x and then sets the listed properties on the copy. The original never changes. It works on records, on any struct (C# 10), and on anonymous types. The copy is shallow. Reference members are shared between original and copy.

Why and when

Use it for “change one field” on immutable data. Examples are reducers, state updates and test builders. It replaces hand-written WithX methods.

Sample

/// <summary>Application settings as an immutable record.</summary>
/// <example>new AppSettings("dark", 12, ["en"])</example>
public sealed record AppSettings(string Theme, int FontSize, List<string> Languages);

public static class WithDemo
{
    /// <summary>Changes one field. The original stays as it was.</summary>
    /// <returns>Both font sizes.</returns>
    /// <example>WithDemo.CopyOneField() returns "original=12 copy=14"</example>
    public static string CopyOneField()
    {
        // 12 is a sample font size.
        var original = new AppSettings("dark", 12, ["en"]);
        // + 2 makes the copy slightly larger.
        AppSettings bigger = original with { FontSize = original.FontSize + 2 };
        return $"original={original.FontSize} copy={bigger.FontSize}";
    }

    /// <summary>Shows the shallow copy: both records share one list.</summary>
    /// <returns>The original's languages after editing the copy's list.</returns>
    /// <example>WithDemo.ShallowCopy() returns ["en", "fr"]</example>
    public static List<string> ShallowCopy()
    {
        var original = new AppSettings("dark", 12, ["en"]);
        AppSettings copy = original with { Theme = "light" };
        copy.Languages.Add("fr");          // same list object as original.Languages
        return original.Languages;
    }

    /// <summary>Uses with on a struct, allowed since C# 10.</summary>
    /// <returns>The moved point.</returns>
    /// <example>WithDemo.StructWith() returns "(5, 2)"</example>
    public static string StructWith()
    {
        // (1, 2) is a sample point. X moves to 5.
        var p = new Point(1, 2);
        Point moved = p with { X = 5 };
        return $"({moved.X}, {moved.Y})";
    }
}

Pitfalls

Interview questions

Q. Does with run the record's constructor?
A. No. It calls a hidden clone method that uses the copy constructor, then runs the init setters you listed. Any validation must live in init accessors to apply to copies.

8. Deconstruction and tuples

ValueTuple and deconstruction: C# 7tuple ==: C# 7.3

What it is

(int Min, int Max) is a ValueTuple<int, int>, a mutable struct with optional element names. Names exist only at compile time. Deconstruction splits a value into variables: var (lo, hi) = MinMax(xs);. It works on tuples, on records, and on any type with a Deconstruct method. Tuples support == element by element.

Why and when

Sample

/// <summary>A class with a hand-written Deconstruct, so it supports deconstruction.</summary>
/// <example>var (h, m) = new Clock(9, 30);</example>
public sealed class Clock(int hour, int minute)
{
    /// <summary>Splits the clock into hour and minute.</summary>
    public void Deconstruct(out int h, out int m) => (h, m) = (hour, minute);
}

public static class TupleDemo
{
    /// <summary>Finds min and max in one pass and returns a named tuple.</summary>
    /// <param name="xs">A non-empty array.</param>
    /// <returns>(Min, Max).</returns>
    /// <example>MinMax([3, 1, 4]) returns (1, 4)</example>
    public static (int Min, int Max) MinMax(int[] xs)
    {
        if (xs.Length == 0) throw new ArgumentException("empty", nameof(xs));
        // Start both at the first element, index 0.
        (int lo, int hi) = (xs[0], xs[0]);
        // Invariant: lo and hi are the min and max of everything seen so far.
        foreach (int x in xs)
            (lo, hi) = (Math.Min(lo, x), Math.Max(hi, x));
        return (lo, hi);
    }

    /// <summary>Deconstructs a tuple, a record and a class in one method.</summary>
    /// <returns>The pieces joined into text.</returns>
    /// <example>TupleDemo.AllKinds() returns "1-4 pen:3 9:30"</example>
    public static string AllKinds()
    {
        var (lo, hi) = MinMax([3, 1, 4]);
        var (name, price) = new Item("pen", 3);     // record: generated Deconstruct
        var (h, m) = new Clock(9, 30);              // class: hand-written Deconstruct
        return $"{lo}-{hi} {name}:{price} {h}:{m}";
    }

    /// <summary>Swaps two values with tuple assignment.</summary>
    /// <returns>The swapped pair.</returns>
    /// <example>TupleDemo.Swap() returns (2, 1)</example>
    public static (int, int) Swap()
    {
        int a = 1, b = 2;      // sample values
        (a, b) = (b, a);
        return (a, b);
    }

    /// <summary>Counts cells on a grid with a tuple key.</summary>
    /// <returns>Visits to cell (0, 1).</returns>
    /// <example>TupleDemo.GridKey() returns 2</example>
    public static int GridKey()
    {
        var visits = new Dictionary<(int Row, int Col), int>();
        // (0, 1) twice and (1, 0) once: tuples hash and compare by value.
        foreach (var cell in new[] { (0, 1), (1, 0), (0, 1) })
            visits[cell] = visits.GetValueOrDefault(cell) + 1;   // + 1 per visit
        return visits[(0, 1)];
    }

    /// <summary>Shows that tuple names do not affect equality.</summary>
    /// <returns>Whether two tuples with different names but equal values are equal.</returns>
    /// <example>TupleDemo.NamesIgnored() returns true</example>
    public static bool NamesIgnored()
    {
        (int A, int B) left = (1, 2);
        (int X, int Y) right = (1, 2);
        return left == right;
    }
}

Pitfalls

Interview questions

Q. When do you return a tuple and when a record?
A. A tuple for private helpers with two or three obvious values. A record for anything public, serialised, or reused, because names and equality are part of the type.

9. init and required members

init: C# 9required: C# 11

What it is

An init accessor allows setting a property in an object initializer or a constructor, and never after. required (C# 11) forces callers to set the member in the initializer. Leaving it out is a compile error (CS9035). Together they give immutable objects with named, mandatory fields and no long constructor.

Why and when

Use them for options classes, DTOs and config objects with many fields. A ten-parameter constructor is easy to call in the wrong order. A [SetsRequiredMembers] attribute on a constructor tells the compiler that this constructor sets all required members.

Sample

using System.Diagnostics.CodeAnalysis;

/// <summary>Connection options with required and optional init-only members.</summary>
/// <example>new DbOptions { Host = "db", Database = "app" }</example>
public sealed class DbOptions
{
    /// <summary>Server host name. Callers must set it.</summary>
    public required string Host { get; init; }
    /// <summary>Database name. Callers must set it.</summary>
    public required string Database { get; init; }
    /// <summary>TCP port. 5432 is the PostgreSQL default.</summary>
    public int Port { get; init; } = 5432;

    /// <summary>Timeout in seconds. init validates on every set, including in with.</summary>
    public int TimeoutSeconds
    {
        get;
        // Below 1 second makes no sense for a connection timeout.
        init => field = value >= 1 ? value : throw new ArgumentOutOfRangeException(nameof(value));
    } = 30;   // 30 seconds is a common default

    /// <summary>Parameterless constructor for object initializers.</summary>
    public DbOptions() { }

    /// <summary>A constructor that sets every required member, so callers may skip them.</summary>
    /// <example>new DbOptions("db", "app").Port is 5432</example>
    [SetsRequiredMembers]
    public DbOptions(string host, string database) => (Host, Database) = (host, database);

    /// <summary>Builds a connection string.</summary>
    /// <returns>"host:port/database".</returns>
    /// <example>new DbOptions("db", "app").Describe() returns "db:5432/app"</example>
    public string Describe() => $"{Host}:{Port}/{Database}";
}

public static class RequiredDemo
{
    /// <summary>Builds options both ways.</summary>
    /// <returns>Both descriptions.</returns>
    /// <example>RequiredDemo.Build() returns "db:6543/app | local:5432/test"</example>
    public static string Build()
    {
        // Leaving out Host or Database here is a compile error (CS9035).
        var a = new DbOptions { Host = "db", Database = "app", Port = 6543 };
        var b = new DbOptions("local", "test");
        // a.Port = 1;  would not compile: init-only after construction.
        return $"{a.Describe()} | {b.Describe()}";
    }
}

The TimeoutSeconds accessor uses the C# 14 field keyword. It refers to the compiler-made backing field, so you can add logic without declaring one by hand.

Pitfalls

Interview questions

Q. What is the difference between init and required?
A. init controls when a property can be set: only during construction. required controls whether it must be set: the caller has to set it. They are often used together.

10. Primary constructors and capture pitfalls

records: C# 9classes and structs: C# 12

What it is

Since C# 12 any class or struct can declare parameters on its type line: class Service(ILogger log). The parameters are in scope in the whole body. Unlike records, a class gets no public properties from them. If a member uses a parameter after construction, the compiler captures it in a hidden private field. That field is mutable, and it is separate from any property you initialised from the same parameter.

class Counter(int count) { public int Count { get; set; } = count; ... count++ ... } new Counter(5) parameter count = 5 Count property backing field copy 1, starts at 5 hidden captured field copy 2, starts at 5 Count = 10 sets this count++ bumps this Two copies drift apart. The compiler warns with CS9124 when it sees this. Fix: use only the property after construction, never the parameter.
Figure A6.3 — A primary constructor parameter used both in an initialiser and in a method ends up stored twice.

Reading the figure. Grey is the constructor call. Blue is the property's storage. Red is the hidden field the compiler adds because a method mentions count. Each arrow is a separate copy of the value 5. After Count = 10, the method still sees its own copy.

Why and when

Primary constructors shine for dependency injection. class OrderService(IRepo repo, ILogger log) removes the fields, the constructor and the assignments. Use them when parameters are just stored and used. Avoid them when you need validation, readonly guarantees or several constructors with different logic.

Sample

/// <summary>Typical DI use: parameters are captured and used by methods.</summary>
/// <example>new Greeter("Hi").Greet("ann") returns "Hi, ann"</example>
public sealed class Greeter(string greeting)
{
    /// <summary>Builds a greeting.</summary>
    /// <param name="name">Who to greet.</param>
    /// <returns>The greeting text.</returns>
    /// <example>Greet("bo") returns "Hi, bo" for greeting "Hi"</example>
    public string Greet(string name) => $"{greeting}, {name}";
}

/// <summary>The double-storage bug: the parameter is captured and also copied.</summary>
/// <example>See PrimaryCtorDemo.DoubleStorage</example>
public sealed class BuggyCounter(int count)
{
#pragma warning disable CS9124 // parameter captured and also used to initialise a member
    /// <summary>Initialised from count once. Separate storage from the capture.</summary>
    public int Count { get; set; } = count;

    /// <summary>Bumps the captured copy, not Count. This is the bug.</summary>
    /// <returns>The captured value after the bump.</returns>
    public int Increment() => ++count;
#pragma warning restore CS9124
}

/// <summary>The fix: validate once, store in a readonly field, never touch the parameter again.</summary>
/// <example>new SafeCounter(5).Increment() returns 6</example>
public sealed class SafeCounter(int start)
{
    // Validate here. Below 0 is not a valid start for this counter.
    private int _count = start >= 0 ? start : throw new ArgumentOutOfRangeException(nameof(start));

    /// <summary>The current value.</summary>
    public int Count => _count;

    /// <summary>Adds one and returns the new value.</summary>
    /// <returns>The count after adding 1.</returns>
    public int Increment() => ++_count;
}

/// <summary>A struct with a primary constructor. Same rules as classes.</summary>
/// <example>new Celsius(100).Fahrenheit is 212</example>
public readonly struct Celsius(double degrees)
{
    /// <summary>The temperature in Fahrenheit: F = C * 9 / 5 + 32.</summary>
    public double Fahrenheit => degrees * 9 / 5 + 32;   // 9 / 5 and 32 are the conversion constants
}

public static class PrimaryCtorDemo
{
    /// <summary>Shows the two copies drifting apart.</summary>
    /// <returns>The property and the captured value after a set and a bump.</returns>
    /// <example>PrimaryCtorDemo.DoubleStorage() returns "Count=10 captured=6"</example>
    public static string DoubleStorage()
    {
        // 5 is the shared starting value. 10 is set through the property only.
        var c = new BuggyCounter(5);
        c.Count = 10;
        int captured = c.Increment();
        return $"Count={c.Count} captured={captured}";
    }
}

Pitfalls

Interview questions

Q. How do primary constructors differ between records and classes?
A. A record turns each parameter into a public init property and includes it in equality and Deconstruct. A class only gets parameters in scope. They are captured into hidden mutable fields if members use them.
Q. What is the capture pitfall?
A. Using a parameter in a property initialiser and also in a method stores it twice. Updating one copy does not update the other. Use the parameter in one place only.

11. Collection expressions and spread

C# 12

What it is

A collection expression [a, b, c] creates a collection of whatever type the target needs. That can be an array, List<T>, Span<T>, ImmutableArray<T>, HashSet<T>, or an interface like IEnumerable<T>. [] is an empty collection. The spread element ..xs inlines every item of another collection.

Why and when

One syntax for every collection type, and it is often faster than hand-written code. The compiler knows the final size and can pick the best construction. For an empty array target it uses Array.Empty<T>(). Spread replaces Concat(...).ToArray() and AddRange chains.

Sample

using System.Collections.Immutable;

public static class CollectionExpressions
{
    /// <summary>Creates several collection types with the same syntax.</summary>
    /// <returns>The type names and sizes.</returns>
    /// <example>CollectionExpressions.Targets() returns "Int32[]:3 List`1:3 HashSet`1:2 ImmutableArray`1:3"</example>
    public static string Targets()
    {
        int[] array = [1, 2, 3];
        List<int> list = [1, 2, 3];
        HashSet<int> set = [1, 1, 2];               // duplicate 1 collapses: 2 items
        ImmutableArray<int> frozen = [1, 2, 3];
        return $"{array.GetType().Name}:{array.Length} {list.GetType().Name}:{list.Count} " +
               $"{set.GetType().Name}:{set.Count} {frozen.GetType().Name}:{frozen.Length}";
    }

    /// <summary>Joins collections with spread, adding items around them.</summary>
    /// <param name="head">First part.</param>
    /// <param name="tail">Second part.</param>
    /// <returns>0, then head, then tail, then 99.</returns>
    /// <example>Wrap([1, 2], [3]) returns [0, 1, 2, 3, 99]</example>
    public static int[] Wrap(int[] head, List<int> tail) =>
        [0, .. head, .. tail, 99];                 // 0 and 99 are sample sentinels

    /// <summary>An empty collection for an interface target.</summary>
    /// <returns>An empty sequence.</returns>
    /// <example>CollectionExpressions.Nothing().Count() returns 0</example>
    public static IEnumerable<string> Nothing() => [];

    /// <summary>Adds an item immutably, building a new array.</summary>
    /// <param name="xs">The original array. It is not changed.</param>
    /// <param name="x">The item to append.</param>
    /// <returns>A new array with x at the end.</returns>
    /// <example>Append([1, 2], 3) returns [1, 2, 3]</example>
    public static int[] Append(int[] xs, int x) => [.. xs, x];

    /// <summary>A span from a collection expression, stack allocated when possible.</summary>
    /// <returns>The sum of the span.</returns>
    /// <example>CollectionExpressions.SpanSum() returns 10</example>
    public static int SpanSum()
    {
        ReadOnlySpan<int> digits = [1, 2, 3, 4];   // constants: no heap allocation needed
        // total of the digits seen so far. 0 is the empty sum.
        int total = 0;
        foreach (int d in digits) total += d;
        return total;
    }
}

Pitfalls

Interview questions

Q. What does [.. a, .. b] do, and why prefer it to a.Concat(b).ToArray()?
A. It builds a new collection with all of a then all of b. When both counts are known, the compiler allocates the exact size once and copies. Concat goes through enumerators and may resize a buffer.
Say this out loud: “I model data as records and take it apart with switch expressions. I let exhaustiveness warnings tell me what I missed. I remember that record equality and with are shallow, and that primary constructor parameters in a class are captured as mutable fields.”

Recap

The things to carry forward

Where this goes next

Patterns like is not null and { } lead straight into nullable reference types. A 07, Null Safety and Errors, covers the null analysis and exception design that make these patterns safe.


← A 05 — Async and Await A 07 — Null Safety and Errors →