Steven's Knowledge

Language Features

Records, pattern matching, nullable reference types, and the modern syntax that makes C# concise -- primary constructors, collection expressions, required members, and generic math

Language Features

The features that define modern C# are not the object-oriented bones it inherited from the Java era -- they are the additions of the last five or six language versions. Records, pattern matching, and nullable reference types changed how idiomatic C# is written, and the C# 11/12 syntax that follows them removed most of the remaining ceremony. If you learned the language a decade ago and stopped, this is the part that will feel unfamiliar, and it is the part worth relearning.

Records and Immutability

Records (C# 9) are reference types with value semantics generated for you. Declare one with positional parameters and the compiler synthesizes a constructor, public init-only properties, value-based Equals and GetHashCode, a readable ToString, and -- crucially -- the with expression for non-destructive mutation. The point is to make immutable data cheap to write, so you reach for it instead of hand-rolling a mutable class and hoping nobody mutates it.

Value-based equality is the headline. Two records with the same field values are equal, full stop, which is exactly what you want for DTOs, domain values, and message types. The with expression copies the record and overrides the named members, leaving the original untouched -- the foundation of immutable update patterns without the boilerplate.

public record Money(decimal Amount, string Currency)
{
    public Money Add(Money other)
    {
        if (other.Currency != Currency)
            throw new InvalidOperationException("Currency mismatch");
        return this with { Amount = Amount + other.Amount };
    }
}

var price = new Money(100m, "NZD");
var withTax = price with { Amount = 115m };  // non-destructive mutation

var a = new Money(100m, "NZD");
var b = new Money(100m, "NZD");
Console.WriteLine(a == b);  // True -- compares by value, not reference

When you want value semantics and a value type -- no heap allocation, no reference identity -- use record struct. It gives you the same generated equality and with support on a struct, which matters in hot paths where you would otherwise pay for an allocation per object. Prefer readonly record struct unless you have a reason to allow mutation; an immutable value type is almost always what you actually meant.

Pattern Matching

Pattern matching turned the switch statement into a switch expression -- an expression that returns a value, matches against shapes rather than just constants, and reads top to bottom until a pattern hits. Property patterns let you destructure into nested members, relational patterns (> 100) compare, and the discard _ is the catch-all. The result is branching logic that is dense but genuinely readable.

public decimal CalculateShipping(Order order) => order switch
{
    { Total: > 100 } => 0m,
    { Destination.Country: "NZ", Weight: < 2 } => 5m,
    { Destination.Country: "NZ" } => 10m,
    { Destination.Country: "AU" } => 25m,
    _ => 50m
};

Type patterns match on runtime type and bind a typed variable in one step, and a when guard adds an arbitrary condition on top. This is the clean replacement for the old is-cast-and-check ladder, and it is the natural way to handle a closed set of subtypes or an object you have to dispatch on.

public string Describe(object value) => value switch
{
    int n when n < 0      => "negative integer",
    int n                 => $"integer {n}",
    string { Length: 0 }  => "empty string",
    string s              => $"string of length {s.Length}",
    null                  => "null",
    _                     => value.GetType().Name
};

List patterns (C# 11) extend the same idea to sequences -- [] matches empty, [var first, ..] binds the head and ignores the tail, [.., var last] grabs the end. They are handy for parsing and for the kind of head/tail recursion that functional languages take for granted.

Nullable Reference Types

Nullable reference types (NRT) are the single highest-value feature for correctness, and they are opt-in. Turn them on with <Nullable>enable</Nullable> in the project file and the compiler starts distinguishing string (must not be null) from string? (may be null), then runs flow analysis to flag every place you dereference a possibly-null value without checking it first. It is not a runtime guarantee -- it is a compile-time warning system -- but it catches the overwhelming majority of NullReferenceException bugs before they ship.

The ! null-forgiving operator tells the compiler "trust me, this isn't null here." It is occasionally necessary at boundaries the analysis can't see through, but every ! is a place you have overridden the safety net, so treat them as code smells to justify rather than reach for.

Turn NRT on day one of a new project. Retrofitting it onto a large existing codebase is a slog -- the warnings arrive by the thousand, and you end up annotating file by file with #nullable enable while the rest stays in the dark. Greenfield, it costs you nothing; brownfield, it is a project of its own.

Primary Constructors and Concise Syntax

Primary constructors (C# 12) let you declare constructor parameters directly on the class or struct header, and those parameters are in scope throughout the body. For dependency injection this erases the classic triple-repetition -- field, constructor parameter, assignment -- down to a single line. The parameters are captured automatically wherever you use them.

public class OrderService(IOrderStore store, IClock clock)
{
    public async Task<Order> GetAsync(string id, CancellationToken ct) =>
        await store.GetAsync(id, ct) ?? throw new OrderNotFoundException(id);
}

Several smaller features pull in the same direction. Top-level statements let a Program.cs skip the class Program/static void Main ceremony and just start executing. File-scoped namespaces (namespace Acme.Orders;) drop a level of indentation off every file. Target-typed new() lets you write List<Order> orders = new(); without repeating the type. None of these change semantics; they just remove noise that never carried information.

For object initialization, required members and init-only setters give you immutability without forcing everything through a constructor. init setters can be assigned only during initialization, and required forces the caller to set the member -- so you get the readability of an object initializer with the compiler guaranteeing nothing essential is left unset.

public class Customer
{
    public required string Name { get; init; }
    public required string Email { get; init; }
    public string? Phone { get; init; }
}

var c = new Customer { Name = "Kiri", Email = "kiri@example.nz" };
// omitting Name or Email is a compile error

Collection Expressions

Collection expressions (C# 12) give every collection a single, uniform literal syntax: [...]. The same brackets target-type to an array, a List<T>, a Span<T>, or anything with the right shape, and the compiler picks the efficient construction for the target. The spread operator .. inlines the elements of one collection into another.

int[] primes = [2, 3, 5, 7, 11];
List<string> regions = ["Auckland", "Wellington", ..southIsland];
Span<int> buffer = [0, 0, 0, 0];

This replaces a grab-bag of older forms -- new[] { ... }, new List<T> { ... }, Enumerable.Concat, ToArray chains -- with one notation that reads the same regardless of the destination type. It is small, but it is the kind of small that shows up on every other line.

Generic Math and Static Abstract Members

Static abstract interface members (C# 11) let an interface require static methods and operators on its implementers, which finally made generic arithmetic possible. Before this, you could not write a method that summed "any number" because the + operator had no common interface; now INumber<T> and its relatives expose operators as static abstract members, and a generic constraint can demand them.

public static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    T total = T.Zero;
    foreach (var v in values)
        total += v;
    return total;
}

// works for int, double, decimal, and any INumber<T>
var totalInts = Sum([1, 2, 3, 4]);
var totalDecimals = Sum([1.5m, 2.5m]);

Most application code never writes a static abstract interface of its own -- this is library-grade machinery, the foundation under things like LINQ aggregates and numeric helpers. But it is worth understanding, because it explains how a single generic method can suddenly work across every numeric type, and it is the answer when someone asks why you can't constrain a generic to "things that add."

Sharp Edges

A few of these features cut if you forget their limits. Record equality is shallow: the generated Equals compares each member, but a member that is itself a reference type is compared by reference, so two records holding distinct-but-equal lists are not equal. If a record contains a collection, its value equality is not what you expect.

Nullable reference types are compile-time only and can be defeated -- a !, a cast, reflection, or deserialization can all hand you a null the analysis swore couldn't exist. NRT moves the odds heavily in your favor; it does not move the guarantee to runtime.

And pattern matching exhaustiveness only warns for non-enum types -- a switch expression that misses a case produces a warning and a runtime SwitchExpressionException, not a compile error. For enums and closed hierarchies you get help; for open object switches you are on your own, so keep the _ arm honest.

On this page