Advanced C# A 07 12 features

Null Safety and Errors

Most production bugs are a null nobody expected or an error nobody handled. C# gives you tools for both. Use them on purpose.

This page covers two linked topics. The first half is about null: nullable reference types, the attributes that teach the compiler your intent, and the null operators. The second half is about failure: overflow, exceptions, guard helpers, and the alternatives to exceptions. Each section names the C# or .NET version that added the feature.

Contents

  1. Nullable reference types
  2. Nullable attributes
  3. The null-forgiving operator
  4. Null operators and ?. assignment
  5. Required members
  6. checked, unchecked and checked operators
  7. Exception filters and throw expressions
  8. Rethrow vs throw ex
  9. Custom exceptions
  10. Guard helpers and CallerArgumentExpression
  11. The Try pattern vs exceptions
  12. Result types
  13. Recap

1. Nullable reference types and flow analysis C# 8

What it is

With <Nullable>enable</Nullable>, a plain string means “never null”. A string? means “may be null”. The compiler tracks a null state for every variable as your code runs. A check like is null moves the state from “maybe null” to “not null”. Using a maybe-null value gives a warning.

This is a compile-time feature only. The runtime type is the same System.String. Nothing stops a caller from passing null at run time, so public APIs still check their inputs.

string? name state: maybe null if (name is null) return 0 name.Length state: not null false name.Length here warns no check yet
Figure A7.1 — The compiler follows each branch and knows the value is not null after the early return.

Reading the figure. Amber is the maybe-null state. Green is the not-null state after the check. The red dashed path shows a use before any check, which earns warning CS8602.

Why and when

Sample

public static class NullFlow
{
    /// <summary>Returns the length of a name, or 0 when the name is missing.</summary>
    /// <param name="name">A name that may be null.</param>
    /// <returns>The number of characters, or 0 for null.</returns>
    /// <example>NullFlow.SafeLength(null) returns 0.</example>
    public static int SafeLength(string? name)
    {
        // State here is "maybe null", so name.Length would warn.
        if (name is null)
        {
            return 0;   // 0 because a missing name has no characters
        }
        // The early return proves name is "not null" from here on.
        return name.Length;
    }

    /// <summary>Picks the first name that is not null or empty.</summary>
    /// <param name="names">Candidates, some may be null.</param>
    /// <returns>The first usable name, or "anonymous".</returns>
    /// <example>NullFlow.FirstName([null, "", "Bo"]) returns "Bo".</example>
    public static string FirstName(IEnumerable<string?> names)
    {
        // n is the current candidate. Invariant: every earlier one was null or empty.
        foreach (string? n in names)
        {
            // IsNullOrEmpty is marked [NotNullWhen(false)], so n is not null inside.
            if (!string.IsNullOrEmpty(n))
            {
                return n;
            }
        }
        // Fallback text when no candidate was usable.
        return "anonymous";
    }
}

Test

T.Check("length of null", NullFlow.SafeLength(null), 0);
T.Check("length of Ada", NullFlow.SafeLength("Ada"), 3);
T.Check("first usable", NullFlow.FirstName([null, "", "Bo"]), "Bo");

Pitfalls

Interview questions

2. Nullable attributes C# 8, .NET Core 3.0 MemberNotNull: C# 9

What it is

Some methods have null rules that a ? cannot express. The attributes in System.Diagnostics.CodeAnalysis describe them. The compiler then reasons across the call.

Why and when

Use them on any Try method, any helper that sets fields, and any pass-through like Trim. Callers then get no false warnings and no missed ones. Library authors should treat them as part of the public API.

Sample

using System.Diagnostics.CodeAnalysis;

public static class NullAttrs
{
    // A tiny user table so the Try method has something to look up.
    private static readonly Dictionary<int, string> Users = new()
    {
        [1] = "ada",     // id 1: sample user
        [2] = "linus",   // id 2: sample user
    };

    /// <summary>Looks up a user name by id.</summary>
    /// <param name="id">The user id.</param>
    /// <param name="name">The name when found. Not null when the result is true.</param>
    /// <returns>True when the id exists.</returns>
    /// <example>NullAttrs.TryFindUser(1, out var n) returns true with n = "ada".</example>
    public static bool TryFindUser(int id, [NotNullWhen(true)] out string? name)
        => Users.TryGetValue(id, out name);

    /// <summary>Upper-cases a user name, or says "unknown".</summary>
    /// <param name="id">The user id.</param>
    /// <returns>The upper-case name or "unknown".</returns>
    /// <example>NullAttrs.Describe(1) returns "ADA".</example>
    public static string Describe(int id)
        // No warning on name.ToUpperInvariant(): the true branch proves name is not null.
        => TryFindUser(id, out string? name) ? name.ToUpperInvariant() : "unknown";

    /// <summary>Finds the first item that matches.</summary>
    /// <param name="items">Items to scan.</param>
    /// <param name="match">The test each item must pass.</param>
    /// <param name="found">The match, or default when none.</param>
    /// <returns>True when a match exists.</returns>
    /// <example>NullAttrs.TryFirst([1, 2, 3], x => x > 1, out var f) gives f = 2.</example>
    public static bool TryFirst<T>(IEnumerable<T> items, Func<T, bool> match,
        [MaybeNullWhen(false)] out T found)
    {
        // item is the current element. Invariant: no earlier element matched.
        foreach (T item in items)
        {
            if (match(item))
            {
                found = item;
                return true;
            }
        }
        // default is null for classes and 0 for numbers, hence MaybeNullWhen(false).
        found = default;
        return false;
    }

    /// <summary>Trims text but keeps null as null.</summary>
    /// <param name="text">Text or null.</param>
    /// <returns>Trimmed text, or null when the input was null.</returns>
    /// <example>NullAttrs.Trim("  hi ") returns "hi".</example>
    [return: NotNullIfNotNull(nameof(text))]
    public static string? Trim(string? text) => text?.Trim();
}

public sealed class Connection
{
    // Set by Open. The attribute on Open tells the compiler when it is safe.
    private string? _host;

    public Connection(string host) => Open(host);

    /// <summary>Points the connection at a new host.</summary>
    /// <param name="host">The host name.</param>
    /// <returns>The length of the host name, read without a null warning.</returns>
    /// <example>new Connection("a").Reopen("db") returns 2.</example>
    public int Reopen(string host)
    {
        Open(host);
        // MemberNotNull on Open proves _host is set here.
        return _host.Length;
    }

    [MemberNotNull(nameof(_host))]
    private void Open(string host) => _host = host;
}

Test

T.Check("describe 1", NullAttrs.Describe(1), "ADA");
T.Check("describe 9", NullAttrs.Describe(9), "unknown");
T.Check("try first", NullAttrs.TryFirst([1, 2, 3], x => x > 1, out int f) ? f : -1, 2);
T.Check("trim null", NullAttrs.Trim(null), null);
T.Check("reopen", new Connection("a").Reopen("db"), 2);

Pitfalls

Interview questions

3. The null-forgiving operator C# 8

What it is

A postfix ! says “I know this is not null”. It turns off the warning for that one expression. It emits no code and no check. If you are wrong, you still get a NullReferenceException later.

Why and when

Sample

public sealed class Fixture
{
    // null! promises the compiler that Setup runs before any read.
    public string ConnectionString { get; private set; } = null!;

    /// <summary>Simulates a framework that fills the property after construction.</summary>
    /// <param name="cs">The connection string.</param>
    /// <returns>The same fixture, now ready.</returns>
    /// <example>new Fixture().Setup("db").ConnectionString returns "db".</example>
    public Fixture Setup(string cs)
    {
        ConnectionString = cs;
        return this;
    }
}

public static class Forgiving
{
    /// <summary>Sums the lengths of the non-null strings, using ! after a filter.</summary>
    /// <param name="items">Strings, some may be null.</param>
    /// <returns>Total length.</returns>
    /// <example>Forgiving.TotalWithBang(["ab", null, "c"]) returns 3.</example>
    public static int TotalWithBang(IEnumerable<string?> items)
        // Where does not change the element type, so s! silences the warning.
        => items.Where(s => s is not null).Sum(s => s!.Length);

    /// <summary>Same total, but OfType filters and narrows the type in one step.</summary>
    /// <param name="items">Strings, some may be null.</param>
    /// <returns>Total length.</returns>
    /// <example>Forgiving.TotalWithOfType(["ab", null, "c"]) returns 3.</example>
    public static int TotalWithOfType(IEnumerable<string?> items)
        => items.OfType<string>().Sum(s => s.Length);
}

Test

T.Check("fixture", new Fixture().Setup("db").ConnectionString, "db");
T.Check("bang total", Forgiving.TotalWithBang(["ab", null, "c"]), 3);
T.Check("oftype total", Forgiving.TotalWithOfType(["ab", null, "c"]), 3);

Pitfalls

Interview questions

4. Null operators: ?. ?? ??= and null-conditional assignment C# 6, C# 8 ?. assignment: C# 14

What it is

Why and when

They replace nested if (x != null) blocks with one readable line. Use ??= for lazy init. Use ?. assignment when an optional object may or may not be there and you only want to update it if it is.

Sample

public sealed class Address
{
    public string? City { get; set; }
}

public sealed class Customer
{
    public Address? Address { get; set; }
    public List<string>? Tags { get; set; }
    public int Visits { get; set; }
}

public static class NullOps
{
    /// <summary>Reads a city through two optional links.</summary>
    /// <param name="c">A customer or null.</param>
    /// <returns>The city, or "unknown" when any link is null.</returns>
    /// <example>NullOps.CityOf(null) returns "unknown".</example>
    public static string CityOf(Customer? c) => c?.Address?.City ?? "unknown";

    /// <summary>Counts tags, treating a missing list as empty.</summary>
    /// <param name="c">A customer or null.</param>
    /// <returns>The tag count.</returns>
    /// <example>NullOps.TagCount(new Customer()) returns 0.</example>
    public static int TagCount(Customer? c) => c?.Tags?.Count ?? 0;   // 0: no list, no tags

    /// <summary>Creates the tag list on first use and adds a tag.</summary>
    /// <param name="c">The customer.</param>
    /// <param name="tag">The tag to add.</param>
    /// <returns>The tag count after adding.</returns>
    /// <example>NullOps.AddTag(new Customer(), "vip") returns 1.</example>
    public static int AddTag(Customer c, string tag)
    {
        // ??= creates the list only when it is still null.
        c.Tags ??= [];
        c.Tags.Add(tag);
        return c.Tags.Count;
    }

    /// <summary>Moves a customer to a city, if the customer and address exist.</summary>
    /// <param name="c">A customer or null.</param>
    /// <param name="city">The new city.</param>
    /// <returns>The city after the move, or "unknown".</returns>
    /// <example>NullOps.MoveTo(null, "Oslo") returns "unknown".</example>
    public static string MoveTo(Customer? c, string city)
    {
        // C# 14: the assignment runs only when c and c.Address are both non-null.
        c?.Address?.City = city;
        return CityOf(c);
    }

    /// <summary>Counts a visit, if there is a customer.</summary>
    /// <param name="c">A customer or null.</param>
    /// <returns>The visit count, or -1 for no customer.</returns>
    /// <example>NullOps.Visit(new Customer()) returns 1.</example>
    public static int Visit(Customer? c)
    {
        // Compound null-conditional assignment. ++ is not allowed here, += is.
        c?.Visits += 1;   // 1: one visit
        return c?.Visits ?? -1;   // -1 marks "no customer"
    }
}

Test

T.Check("city null", NullOps.CityOf(null), "unknown");
T.Check("tags none", NullOps.TagCount(new Customer()), 0);
T.Check("add tag", NullOps.AddTag(new Customer(), "vip"), 1);
T.Check("move null", NullOps.MoveTo(null, "Oslo"), "unknown");
T.Check("move ok", NullOps.MoveTo(new Customer { Address = new() }, "Oslo"), "Oslo");
T.Check("visit", NullOps.Visit(new Customer()), 1);
T.Check("visit null", NullOps.Visit(null), -1);

Pitfalls

Interview questions

5. Required members C# 11

What it is

The required modifier on a property or field forces every object initializer to set it. Forget one and the build fails with CS9035. A constructor marked [SetsRequiredMembers] tells the compiler it sets them all, so callers of that constructor need no initializer.

Why and when

Use it for data objects with init properties. It fixes the old gap where a non-nullable property had to be = null! or forced into a big constructor. Serializers such as System.Text.Json also honor required since .NET 7.

Sample

using System.Diagnostics.CodeAnalysis;

public sealed class Order
{
    public required string Id { get; init; }
    public required decimal Total { get; init; }
    public string? Note { get; init; }

    // Keeps the object-initializer path open: new Order { Id = .., Total = .. }.
    public Order() { }

    // The attribute says this constructor sets every required member.
    [SetsRequiredMembers]
    public Order(string id, decimal total)
    {
        Id = id;
        Total = total;
    }
}

public static class Orders
{
    /// <summary>Builds two orders, one each way, and describes them.</summary>
    /// <returns>A short text with both ids and totals.</returns>
    /// <example>Orders.Make() returns "A1:9.5 B2:3".</example>
    public static string Make()
    {
        // Initializer path: leaving out Id or Total would be error CS9035.
        var a = new Order { Id = "A1", Total = 9.5m };   // 9.5m: sample total
        // Constructor path: SetsRequiredMembers removes the need for an initializer.
        var b = new Order("B2", 3m);   // 3m: sample total
        return $"{a.Id}:{a.Total} {b.Id}:{b.Total}";
    }
}

Test

T.Check("orders", Orders.Make(), "A1:9.5 B2:3");

Pitfalls

Interview questions

6. checked, unchecked and checked user-defined operators C# 1 checked operators: C# 11

What it is

Integer math in C# wraps silently by default. int.MaxValue + 1 is int.MinValue. Inside checked(...) or a checked { } block, overflow throws OverflowException. unchecked forces wrapping, even when the project turns on CheckForOverflowUnderflow.

Since C# 11, your own types can offer both forms. You write operator + and operator checked +. The compiler picks the checked one inside a checked context.

Why and when

Sample

public static class Overflow
{
    /// <summary>Adds two ints and lets the result wrap around.</summary>
    /// <param name="a">First value.</param>
    /// <param name="b">Second value.</param>
    /// <returns>The wrapped sum.</returns>
    /// <example>Overflow.AddWrap(int.MaxValue, 1) returns int.MinValue.</example>
    public static int AddWrap(int a, int b) => unchecked(a + b);

    /// <summary>Adds two ints and throws on overflow.</summary>
    /// <param name="a">First value.</param>
    /// <param name="b">Second value.</param>
    /// <returns>The exact sum.</returns>
    /// <example>Overflow.AddChecked(2, 3) returns 5.</example>
    public static int AddChecked(int a, int b) => checked(a + b);

    /// <summary>Sums ints in a long so the total cannot overflow for normal sizes.</summary>
    /// <param name="xs">The values.</param>
    /// <returns>The total as a long.</returns>
    /// <example>Overflow.SumLong([int.MaxValue, 1]) returns 2147483648.</example>
    public static long SumLong(int[] xs)
    {
        long total = 0;   // 0: empty sum
        // x is the current value. Invariant: total holds the sum of all earlier values.
        foreach (int x in xs)
        {
            total += x;
        }
        return total;
    }
}

public readonly record struct Meters(int Value)
{
    // Regular operator: used in unchecked code, wraps like int.
    public static Meters operator +(Meters a, Meters b) => new(unchecked(a.Value + b.Value));

    // C# 11 checked operator: chosen inside checked(...), throws on overflow.
    public static Meters operator checked +(Meters a, Meters b) => new(checked(a.Value + b.Value));
}

public static class MetersMath
{
    /// <summary>Adds in an unchecked context, so the regular operator runs.</summary>
    /// <param name="a">First length.</param>
    /// <param name="b">Second length.</param>
    /// <returns>The wrapped sum.</returns>
    /// <example>MetersMath.Plain(new(1), new(2)).Value returns 3.</example>
    public static Meters Plain(Meters a, Meters b) => a + b;

    /// <summary>Adds in a checked context, so operator checked + runs.</summary>
    /// <param name="a">First length.</param>
    /// <param name="b">Second length.</param>
    /// <returns>The exact sum.</returns>
    /// <example>MetersMath.Safe(new(1), new(2)).Value returns 3.</example>
    public static Meters Safe(Meters a, Meters b) => checked(a + b);
}

Test

T.Check("wrap", Overflow.AddWrap(int.MaxValue, 1), int.MinValue);
T.Throws<OverflowException>("checked add", () => Overflow.AddChecked(int.MaxValue, 1));
T.Check("sum long", Overflow.SumLong([int.MaxValue, 1]), 2147483648L);
T.Check("meters plain", MetersMath.Plain(new(int.MaxValue), new(1)).Value, int.MinValue);
T.Throws<OverflowException>("meters safe", () => MetersMath.Safe(new(int.MaxValue), new(1)));

Pitfalls

Interview questions

7. Exception filters and throw expressions filters: C# 6 throw expressions: C# 7

What it is

A filter adds when (condition) to a catch. The runtime runs the filter before it unwinds the stack. If the filter is false, the search moves on as if that catch were not there.

A throw expression lets throw appear where a value is expected. That is after ??, inside ?:, and in an expression-bodied member.

throw Status = 503 when (Status == 404) false: skip when (Status >= 500) true: pick this catch (ApiError) never tested unwind, run catch body returns "retry"
Figure A7.2 — Filters run first, and only the chosen catch causes the stack to unwind.

Reading the figure. Amber boxes are filters, tested top to bottom while the throwing frame is still on the stack. Green is the one catch that runs. The grey catch is never reached. A debugger or crash dump taken inside a filter still sees the original frames.

Why and when

Sample

public sealed class ApiError(int status, string message) : Exception(message)
{
    public int Status { get; } = status;
}

public static class Filters
{
    /// <summary>Maps an API status code to an action using catch filters.</summary>
    /// <param name="status">The HTTP-like status code to throw.</param>
    /// <returns>"not found", "retry", or "fail".</returns>
    /// <example>Filters.Handle(503) returns "retry".</example>
    public static string Handle(int status)
    {
        try
        {
            throw new ApiError(status, "call failed");
        }
        catch (ApiError e) when (e.Status == 404)   // 404: the standard "not found" code
        {
            return "not found";
        }
        catch (ApiError e) when (e.Status >= 500)   // 500 and up: server errors, worth a retry
        {
            return "retry";
        }
        catch (ApiError)
        {
            return "fail";
        }
    }

    /// <summary>Logs inside a filter that returns false, so an outer catch handles it.</summary>
    /// <returns>Which catch ran, plus how many log lines were written.</returns>
    /// <example>Filters.LogThenPass() returns "outer, logged 1".</example>
    public static string LogThenPass()
    {
        List<string> log = [];
        // Records the message and declines to catch.
        bool LogAndSkip(Exception e)
        {
            log.Add(e.Message);
            return false;
        }

        try
        {
            try
            {
                throw new InvalidOperationException("bad state");
            }
            catch (Exception e) when (LogAndSkip(e))
            {
                return "inner";   // never runs: the filter always says false
            }
        }
        catch (InvalidOperationException)
        {
            return $"outer, logged {log.Count}";
        }
    }

    /// <summary>Returns the name or throws, using a throw expression after ??.</summary>
    /// <param name="name">A name that may be null.</param>
    /// <returns>The same name.</returns>
    /// <example>Filters.Require("x") returns "x".</example>
    public static string Require(string? name)
        => name ?? throw new ArgumentNullException(nameof(name));

    /// <summary>Halves an even number, throwing for odd ones.</summary>
    /// <param name="even">An even number.</param>
    /// <returns>Half of it.</returns>
    /// <example>Filters.Half(8) returns 4.</example>
    public static int Half(int even)
        // % 2 == 0 tests evenness. / 2 halves it.
        => even % 2 == 0 ? even / 2 : throw new ArgumentException("must be even", nameof(even));
}

Test

T.Check("404", Filters.Handle(404), "not found");
T.Check("503", Filters.Handle(503), "retry");
T.Check("400", Filters.Handle(400), "fail");
T.Check("log filter", Filters.LogThenPass(), "outer, logged 1");
T.Throws<ArgumentNullException>("require null", () => Filters.Require(null));
T.Check("half", Filters.Half(8), 4);

Pitfalls

Interview questions

8. Rethrow vs throw ex C# 1 ExceptionDispatchInfo: .NET 4.5

What it is

Why and when

Use throw; whenever you catch to log or clean up and then pass the error on. Use ExceptionDispatchInfo when you store an exception and throw it from somewhere else. Use throw new X("context", ex) to add meaning while keeping the original as InnerException.

Sample

using System.Runtime.CompilerServices;
using System.Runtime.ExceptionServices;

public static class Rethrow
{
    // NoInlining keeps each method as its own stack frame in Release builds.
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void Deep() => throw new InvalidOperationException("deep");

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void WithRethrow()
    {
        try { Deep(); }
        catch (InvalidOperationException) { throw; }   // keeps the frame for Deep
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void WithThrowEx()
    {
        try { Deep(); }
        catch (InvalidOperationException ex) { throw ex; }   // resets the trace here
    }

    /// <summary>Checks whether the final stack trace still names Deep.</summary>
    /// <param name="useRethrow">True for throw;, false for throw ex;.</param>
    /// <returns>True when the original frame survived.</returns>
    /// <example>Rethrow.KeepsOrigin(true) returns true.</example>
    public static bool KeepsOrigin(bool useRethrow)
    {
        try
        {
            if (useRethrow) WithRethrow(); else WithThrowEx();
        }
        catch (Exception ex)
        {
            return ex.StackTrace!.Contains(nameof(Deep));
        }
        return false;
    }

    /// <summary>Stores an exception, rethrows it later, and checks the trace.</summary>
    /// <returns>True because ExceptionDispatchInfo keeps the original frames.</returns>
    /// <example>Rethrow.CapturedKeepsOrigin() returns true.</example>
    public static bool CapturedKeepsOrigin()
    {
        ExceptionDispatchInfo? saved = null;
        try { Deep(); }
        catch (Exception ex) { saved = ExceptionDispatchInfo.Capture(ex); }

        try
        {
            saved?.Throw();
        }
        catch (Exception ex)
        {
            return ex.StackTrace!.Contains(nameof(Deep));
        }
        return false;
    }
}

Test

T.Check("throw; keeps", Rethrow.KeepsOrigin(true), true);
T.Check("throw ex loses", Rethrow.KeepsOrigin(false), false);
T.Check("edi keeps", Rethrow.CapturedKeepsOrigin(), true);

Pitfalls

Interview questions

9. Custom exceptions C# 1

What it is

A custom exception is a class that derives from Exception. It carries data the caller can act on, such as a balance or an error code. A small hierarchy lets callers catch a whole family at once.

Why and when

Sample

public class BankException : Exception
{
    public BankException() { }
    public BankException(string message) : base(message) { }
    public BankException(string message, Exception inner) : base(message, inner) { }
}

public sealed class InsufficientFundsException(decimal balance, decimal requested)
    : BankException($"Requested {requested} but balance is {balance}.")
{
    public decimal Balance { get; } = balance;
    public decimal Requested { get; } = requested;
    // How much more money the account needs.
    public decimal Shortfall => Requested - Balance;
}

public sealed class Account(decimal balance)
{
    public decimal Balance { get; private set; } = balance;

    /// <summary>Takes money out, or throws if there is not enough.</summary>
    /// <param name="amount">A positive amount.</param>
    /// <example>new Account(10m).Withdraw(4m) leaves Balance at 6.</example>
    public void Withdraw(decimal amount)
    {
        // Bad input is a caller bug, so it gets a built-in argument exception.
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
        // Not enough money is a business rule, so it gets the custom type.
        if (amount > Balance)
        {
            throw new InsufficientFundsException(Balance, amount);
        }
        Balance -= amount;
    }
}

public static class Bank
{
    /// <summary>Tries a withdrawal and reports the outcome as text.</summary>
    /// <param name="start">Starting balance.</param>
    /// <param name="amount">Amount to withdraw.</param>
    /// <returns>"ok N" with the new balance, or "short N" with the shortfall.</returns>
    /// <example>Bank.TryWithdraw(10m, 25m) returns "short 15".</example>
    public static string TryWithdraw(decimal start, decimal amount)
    {
        var account = new Account(start);
        try
        {
            account.Withdraw(amount);
            return $"ok {account.Balance}";
        }
        catch (InsufficientFundsException ex)
        {
            // The caller reads data from the exception, not from its message.
            return $"short {ex.Shortfall}";
        }
    }

    /// <summary>Wraps a low-level error in a domain error, keeping the cause.</summary>
    /// <returns>The type name of the inner exception.</returns>
    /// <example>Bank.WrapCause() returns "TimeoutException".</example>
    public static string WrapCause()
    {
        try
        {
            try
            {
                throw new TimeoutException("ledger slow");
            }
            catch (TimeoutException ex)
            {
                throw new BankException("Transfer failed.", ex);
            }
        }
        catch (BankException ex)
        {
            return ex.InnerException?.GetType().Name ?? "none";
        }
    }
}

Test

T.Check("withdraw ok", Bank.TryWithdraw(10m, 4m), "ok 6");
T.Check("withdraw short", Bank.TryWithdraw(10m, 25m), "short 15");
T.Throws<ArgumentOutOfRangeException>("zero", () => new Account(1m).Withdraw(0m));
T.Check("inner kept", Bank.WrapCause(), "TimeoutException");

Pitfalls

Interview questions

10. Guard helpers and CallerArgumentExpression ThrowIfNull: .NET 6 ThrowIfNegative: .NET 8 CallerArgumentExpression: C# 10

What it is

The BCL ships static guard helpers. They throw the right exception with the right parameter name in one line.

They get the parameter name from [CallerArgumentExpression]. That C# 10 attribute makes the compiler pass the source text of an argument as a string. You can use it in your own helpers.

Why and when

Use them at the top of every public method. They are short, consistent, and the JIT keeps the throw path out of the hot code. Write your own helper with CallerArgumentExpression for checks the BCL lacks.

Sample

using System.Runtime.CompilerServices;

public static class Guard
{
    /// <summary>Returns the value if positive, else throws naming the caller expression.</summary>
    /// <param name="value">The value to check.</param>
    /// <param name="expr">Filled in by the compiler with the argument's source text.</param>
    /// <returns>The same value.</returns>
    /// <example>Guard.Positive(5) returns 5.</example>
    public static int Positive(int value,
        [CallerArgumentExpression(nameof(value))] string? expr = null)
        // > 0 because zero is not positive.
        => value > 0 ? value : throw new ArgumentOutOfRangeException(expr, value, "must be > 0");

    /// <summary>Runs an action and reports the ParamName of any ArgumentException.</summary>
    /// <param name="act">Code that may throw.</param>
    /// <returns>The parameter name, or "none" if nothing was thrown.</returns>
    /// <example>Guard.ParamOf(() => Guard.Positive(2 - 5)) returns "2 - 5".</example>
    public static string ParamOf(Action act)
    {
        try
        {
            act();
            return "none";
        }
        catch (ArgumentException ex)
        {
            return ex.ParamName ?? "?";
        }
    }
}

public static class Shipping
{
    /// <summary>Computes a shipping cost after guarding every input.</summary>
    /// <param name="zone">Zone code, must not be blank.</param>
    /// <param name="weightKg">Weight, must not be negative.</param>
    /// <param name="boxes">Box count, must not be zero.</param>
    /// <returns>The cost.</returns>
    /// <example>Shipping.Cost("eu", 2, 1) returns 9.</example>
    public static decimal Cost(string? zone, int weightKg, int boxes)
    {
        // Each helper throws with the parameter name filled in automatically.
        ArgumentException.ThrowIfNullOrWhiteSpace(zone);
        ArgumentOutOfRangeException.ThrowIfNegative(weightKg);
        ArgumentOutOfRangeException.ThrowIfZero(boxes);
        // 5 per box base fee plus 2 per kg: sample price list.
        return 5m * boxes + 2m * weightKg;
    }
}

Test

T.Check("cost", Shipping.Cost("eu", 2, 1), 9m);
T.Check("zone name", Guard.ParamOf(() => Shipping.Cost(null, 1, 1)), "zone");
T.Check("weight name", Guard.ParamOf(() => Shipping.Cost("eu", -1, 1)), "weightKg");
T.Check("boxes name", Guard.ParamOf(() => Shipping.Cost("eu", 1, 0)), "boxes");
T.Check("expr text", Guard.ParamOf(() => Guard.Positive(2 - 5)), "2 - 5");

Pitfalls

Interview questions

11. The Try pattern vs exceptions C# 2 era, out var: C# 7

What it is

A Try method returns bool and hands the result back through an out parameter. int.TryParse and Dictionary.TryGetValue are the classics. The throwing twin, such as int.Parse, is for input you expect to be valid.

Why and when

Sample

public static class Points
{
    /// <summary>Parses "x,y" into a point without throwing.</summary>
    /// <param name="text">Text such as "3,4".</param>
    /// <param name="point">The parsed point, or (0, 0) on failure.</param>
    /// <returns>True when the text was valid.</returns>
    /// <example>Points.TryParse("3,4", out var p) returns true with p = (3, 4).</example>
    public static bool TryParse(string? text, out (int X, int Y) point)
    {
        point = default;
        if (text is null)
        {
            return false;
        }
        string[] parts = text.Split(',');
        // 2 because a point has exactly an x part and a y part.
        if (parts.Length != 2)
        {
            return false;
        }
        // [0] is the x text and [1] is the y text.
        if (!int.TryParse(parts[0], out int x) || !int.TryParse(parts[1], out int y))
        {
            return false;
        }
        point = (x, y);
        return true;
    }

    /// <summary>Parses "x,y" and throws FormatException on bad input.</summary>
    /// <param name="text">Text such as "3,4".</param>
    /// <returns>The point.</returns>
    /// <example>Points.Parse("3,4") returns (3, 4).</example>
    public static (int X, int Y) Parse(string text)
        => TryParse(text, out var p) ? p : throw new FormatException($"Bad point: '{text}'.");

    /// <summary>Sums the x values of every valid point, skipping bad ones.</summary>
    /// <param name="lines">Candidate lines.</param>
    /// <returns>The sum of x over valid lines.</returns>
    /// <example>Points.SumX(["1,2", "oops", "3,4"]) returns 4.</example>
    public static int SumX(IEnumerable<string> lines)
    {
        int sum = 0;   // 0: empty sum
        // line is the current input. Invariant: sum covers every valid earlier line.
        foreach (string line in lines)
        {
            if (TryParse(line, out var p))
            {
                sum += p.X;
            }
        }
        return sum;
    }
}

Test

T.Check("try ok", Points.TryParse("3,4", out var p) ? p.X + p.Y : -1, 7);
T.Check("try bad", Points.TryParse("3;4", out _), false);
T.Check("sum x", Points.SumX(["1,2", "oops", "3,4"]), 4);
T.Throws<FormatException>("parse bad", () => Points.Parse("x"));

Pitfalls

Interview questions

12. Result types pattern, uses records: C# 9

What it is

A Result is a value that holds either a success value or an error. The caller must look at which one it got. C# has no built-in Result, but records and pattern matching make one short to write. Libraries such as FluentResults and OneOf offer richer ones.

Why and when

Sample

using System.Diagnostics;

public abstract record Result<T>
{
    // Private constructor: only the two nested cases can derive, so the set is closed.
    private Result() { }

    public sealed record Ok(T Value) : Result<T>;
    public sealed record Fail(string Error) : Result<T>;

    /// <summary>Transforms the success value and passes errors through.</summary>
    /// <param name="f">The transform.</param>
    /// <returns>A new Result.</returns>
    /// <example>new Result<int>.Ok(2).Map(x => x * 10) gives Ok(20).</example>
    public Result<TOut> Map<TOut>(Func<T, TOut> f) => this switch
    {
        Ok ok => new Result<TOut>.Ok(f(ok.Value)),
        Fail e => new Result<TOut>.Fail(e.Error),
        _ => throw new UnreachableException(),
    };

    /// <summary>Chains a step that can itself fail.</summary>
    /// <param name="f">The next step.</param>
    /// <returns>The next step's result, or the first error.</returns>
    /// <example>Ages.Parse("30").Bind(Ages.Adult) gives Ok(30).</example>
    public Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f) => this switch
    {
        Ok ok => f(ok.Value),
        Fail e => new Result<TOut>.Fail(e.Error),
        _ => throw new UnreachableException(),
    };
}

public static class Ages
{
    /// <summary>Parses an age, failing with a reason instead of throwing.</summary>
    /// <param name="text">Text such as "42".</param>
    /// <returns>Ok with the age, or Fail with a reason.</returns>
    /// <example>Ages.Parse("x") gives Fail("not a number").</example>
    public static Result<int> Parse(string text)
    {
        if (!int.TryParse(text, out int age))
        {
            return new Result<int>.Fail("not a number");
        }
        // 0..150 is a generous range for a human age.
        if (age < 0 || age > 150)
        {
            return new Result<int>.Fail("out of range");
        }
        return new Result<int>.Ok(age);
    }

    /// <summary>Accepts only adults.</summary>
    /// <param name="age">A valid age.</param>
    /// <returns>Ok with the age, or Fail for minors.</returns>
    /// <example>Ages.Adult(12) gives Fail("minor").</example>
    public static Result<int> Adult(int age)
        // 18: the adult age used in this example.
        => age >= 18 ? new Result<int>.Ok(age) : new Result<int>.Fail("minor");

    /// <summary>Runs the whole pipeline and renders the outcome.</summary>
    /// <param name="text">Raw input.</param>
    /// <returns>"adult N" or "error: reason".</returns>
    /// <example>Ages.Check("30") returns "adult 30".</example>
    public static string Check(string text) => Parse(text).Bind(Adult) switch
    {
        Result<int>.Ok(var age) => $"adult {age}",
        Result<int>.Fail(var why) => $"error: {why}",
        _ => throw new UnreachableException(),
    };
}

Test

T.Check("adult", Ages.Check("30"), "adult 30");
T.Check("minor", Ages.Check("12"), "error: minor");
T.Check("nan", Ages.Check("x"), "error: not a number");
T.Check("range", Ages.Check("200"), "error: out of range");
T.Check("map", new Result<int>.Ok(2).Map(x => x * 10) is Result<int>.Ok(20), true);

Pitfalls

Interview questions

Recap

The things to carry forward

Say this out loud: “Expected failures get a Try method or a Result. Bugs and rare failures get exceptions. I guard every public input with the BCL helpers, I rethrow with a bare throw, and I keep nullable warnings as errors.”

Where this goes next

Correct code is the first goal. Fast code is the next. Performance and Memory looks at the GC, dispose, pools and spans.


← A 06 — Pattern Matching and Records A 08 — Performance and Memory →