C#idiomatic C#
patterns-and-recordsidioms

Idioms

Patterns and records

The modern C# shape of a test and a data type: patterns for what the value is, records for what it carries.

Pattern matching and records arrived in the same era and answer the same question from two sides. A pattern says what a value is without a cast and a null check; a record says what a value carries without a hand-written Equals, GetHashCode and ToString.

Test a type with a pattern, not with as

An is pattern combines the type test, the cast and the null check in one expression, and the compiler knows the variable is not null inside the branch. The as form needs two statements and leaks a nullable value.

Preferred

csharp
if (shape is Circle circle)
{
    Draw(circle.Radius);
}

Avoid

csharp
var circle = shape as Circle;
if (circle != null)
{
    Draw(circle.Radius);
}

Corpus: Safely cast using pattern matching, is and as · Type patterns

Compare against null with is null

is null and is not null cannot be intercepted by an overloaded == operator, so they mean what they say on a type that also defines equality. They also read as the null test rather than as an arithmetic comparison.

Preferred

csharp
if (value is not null)
{
    Use(value);
}

Avoid

csharp
if (value != null)
{
    Use(value);
}

Corpus: Declaration, constant and var patterns

A switch expression maps a value, not a flow

When each branch produces a value, a switch expression says so in one place and the compiler checks that every path returns. The statement form is for branches that do work, not for choosing a result.

Preferred

csharp
var name = size switch
{
    <= 0 => "empty",
    < 10 => "small",
    _ => "large",
};

Avoid

csharp
string name;
switch (size)
{
    case int s when s <= 0: name = "empty"; break;
    default: name = "large"; break;
}

Corpus: switch expression · Selection statements

Match the shape with property patterns

A property pattern reads a nested shape without walking into it: no temporary variable, no chain of null checks in front. Combined with and, or and not, it replaces most hand-written conditions about data.

Preferred

csharp
if (order is { Customer.Country: "DE", Total: > 100 })

Avoid

csharp
if (order.Customer != null && order.Customer.Country == "DE" && order.Total > 100)

Corpus: Pattern matching

Add a condition with when

A guard clause keeps the pattern about shape and the condition about the value. It also keeps the condition inside the switch, so the reader sees the whole decision in one place.

Preferred

csharp
case Shape shape when shape.Area > 100:

Avoid

csharp
case Shape shape:
    if (shape.Area <= 100) break;
    // ... the guard is now a flow control decision

Corpus: Pattern matching

Match lists by their shape

A list pattern matches an array or span by length and by the items at its ends, which is the readable form of indexing arithmetic. Empty, single-element and head-and-rest checks all fit in the pattern.

Preferred

csharp
if (values is [var first, .., var last])

Avoid

csharp
if (values.Length > 0 && values[0] == first && values[^1] == last)

Corpus: Patterns

Keep a switch exhaustive

A switch expression on a closed set such as an enum should handle every member and let the compiler prove it, with an exception in place of _ if a new member must not be ignored. A silent _ arm turns a new enum value into a wrong answer.

Preferred

csharp
var verb = state switch
{
    State.Open => "open",
    State.Closed => "closed",
    _ => throw new ArgumentOutOfRangeException(nameof(state)),
};

Avoid

csharp
var verb = state switch { State.Open => "open", _ => "closed" };

Corpus: Selection statements

Use a record for a value that travelsCA1067

A record gives value equality, GetHashCode, ToString and a non-destructive with for free, and the generated Equals follows the fields. A class with the same members hand-written drifts the moment a field is added.

Preferred

csharp
public sealed record Money(decimal Amount, string Currency);

Avoid

csharp
public class Money { /* Amount, Currency, Equals, GetHashCode, ToString, ... */ }

Corpus: Records · Record types · CA1067: Override Equals when implementing IEquatable

Prefer a record struct for a small immutable value

A readonly record struct keeps the value semantics without an allocation, which is the right shape for a key, a coordinate or an amount that is passed by value. A record class is the default when identity matters or the type is large.

Preferred

csharp
public readonly record struct Point(double X, double Y);

Avoid

csharp
public record Point(double X, double Y);   // one allocation per point

Corpus: Record types

Return a tuple for a private answer, a type for a public one

Tuples are unnamed beyond the method signature, so a caller can never add a member or a doc comment to them. Inside a type they are the cheapest way to return two values; across a public boundary they freeze the shape.

Preferred

csharp
public sealed record Range(int Start, int Length);

public Range Measure(ReadOnlySpan<char> text);

Avoid

csharp
public (int Start, int Length) Measure(ReadOnlySpan<char> text);

Corpus: Tuple types · Choosing between anonymous and tuple types

Deconstruct when the caller wants the parts

A Deconstruct method gives a type the same positional syntax a tuple has, so a caller can name what it needs without a member for every combination. Records with positional parameters get it generated.

Preferred

csharp
var (amount, currency) = price;

Avoid

csharp
var amount = price.Amount;
var currency = price.Currency;

Corpus: Deconstructing tuples and other types · Deconstruction

Use a discard for a value you deliberately ignore

A _ at the assignment says the value is not needed, and it works for out parameters and for parts of a tuple. A named but unused variable makes the next reader wonder whether the omission was a mistake.

Preferred

csharp
if (int.TryParse(text, out _))
{
    Use(text);
}

Avoid

csharp
if (int.TryParse(text, out var unused))
{
    Use(text);
}

Corpus: Discards