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.
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.
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.
#nullable enable at the top.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";
}
}
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");
<WarningsAsErrors>nullable</WarningsAsErrors> to make them stick.new string[3] has type string[] but holds three nulls, and the compiler does not warn.T? on an unconstrained generic means “default of T”. For a value type that is 0, not Nullable<T>.string? change the runtime type? No. Both compile to System.String. The ? is metadata for the compiler. int? is different: it is a real Nullable<int> struct.string still be null at run time? Yes. Old code, reflection, deserializers, default structs and ! can all put a null there. Public methods should still guard.Some methods have null rules that a ? cannot express. The attributes in System.Diagnostics.CodeAnalysis describe them. The compiler then reasons across the call.
[NotNullWhen(true)] on an out parameter: not null when the method returns true. Used by TryGetValue.[MaybeNullWhen(false)] on a generic out T: may be default when the method returns false.[NotNullIfNotNull("p")] on the return: the result is not null if parameter p was not null.[MemberNotNull("field")] on a helper: the field is set when the helper returns. Good for init helpers called from a constructor.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.
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;
}
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);
nameof. A typo in a string silently does nothing.[MemberNotNull] is checked inside the helper. If one path does not set the field, you get a warning there.TryGetValue not warn when you use the value inside if? Its out parameter has [MaybeNullWhen(false)]. In the true branch the compiler treats the value as not null.NotNullWhen vs MaybeNullWhen? Use NotNullWhen on a type already marked ?. Use MaybeNullWhen on an unmarked generic T, since T? means something else for value types.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.
= null!.OfType<string>() there, which needs no !.Method(null!).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);
}
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);
! is a claim nobody checks. Grep for them in code review.x!.Foo on a null x still throws. The ! does not make it safe.!= or logical not. x! is postfix and has no runtime effect.! compile to? Nothing. It only changes the compiler’s null state for that expression.= null! on a property acceptable? When something outside the constructor always sets it before use, like a DI container, a test framework, or an ORM. A required member is often the better fix.a?.B (C# 6): read B only if a is not null, else the whole chain is null.a ?? b (C# 2): use a unless it is null, then b.a ??= b (C# 8): assign b to a only when a is null.a?.B = x (C# 14): assign only if a is not null. Compound forms like a?.Count += 1 work too. The right side is not evaluated when a is null.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.
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"
}
}
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);
a?.B = Expensive() skips Expensive() when a is null. Do not rely on its side effects.a?.Count++ and a?.Count-- are not allowed in C# 14. Use += 1.?. on a value-type member returns int?, not int. Add ?? 0 to get back to int.==, ?. and ?? skip the overload. They test true null only.x ??= new() thread-safe? No. Two threads can both see null and both assign. Use Lazy<T> or Interlocked.CompareExchange for shared state.c?.Address?.City = city do when c is null? Nothing. It does not throw, and it does not evaluate city.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.
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.
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}";
}
}
T.Check("orders", Orders.Make(), "A1:9.5 B2:3");
required checks that a value is assigned, not that it is non-null. Id = null! still compiles.[SetsRequiredMembers] is not verified. If the constructor forgets a member, nobody notices.required from a base member.required vs a constructor parameter? Both force a value. required keeps named, order-free initializers and works well with init and with. Constructors can validate across fields.required work with records? Yes, on extra properties. Positional record parameters are already required by the constructor.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.
checked.unchecked so a project-wide setting does not break them.long. Say so before the interviewer asks.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);
}
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)));
checked covers only the code written inside it. A method called from inside uses its own context.int.MaxValue + 1 as a literal is a build error, not a wrap.checked((int)someLong) throws when the value does not fit.double overflow gives infinity.int.MaxValue + 1 at run time? int.MinValue by default, since C# wraps. It throws OverflowException inside checked.INumber<T> code must behave like built-in ints in both contexts. C# 11 added operator checked so user types can match.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.
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.
false keeps the original stack intact._name = name ?? throw new ArgumentNullException(nameof(name));.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));
}
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);
finally blocks of inner frames. Do not depend on cleanup having happened.catch (E e) when (cond) better than catch (E e) { if (!cond) throw; }? The filter does not unwind the stack when false. Crash dumps and debuggers keep the throw site. The rethrow version unwinds first.??, in either arm of ?:, and as the body of an expression-bodied member or lambda.throw; rethrows the current exception and keeps its stack trace.throw ex; throws the same object but resets the trace to the current line. The original frames are lost.ExceptionDispatchInfo.Capture(ex).Throw() rethrows later, even on another thread, and keeps the original trace. await uses it under the hood.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.
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;
}
}
T.Check("throw; keeps", Rethrow.KeepsOrigin(true), true);
T.Check("throw ex loses", Rethrow.KeepsOrigin(false), false);
T.Check("edi keeps", Rethrow.CapturedKeepsOrigin(), true);
throw ex;. Treat it as an error.Exception and returning a default hides bugs. Catch the narrowest type you can handle.throw new X("msg") without it loses the cause.throw; and throw ex;? Both throw the same object. throw; keeps the original stack trace. throw ex; restarts it at the current line.await rethrow a task’s exception with the right trace? It uses ExceptionDispatchInfo, which saves and restores the trace across threads.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.
ArgumentException or InvalidOperationException.Exception. Offer the three standard constructors.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";
}
}
}
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");
ApplicationException. The guidance dropped it long ago.(SerializationInfo, StreamingContext) is obsolete since .NET 8. Do not add it in new code.InnerException? It preserves the root cause and its stack trace. Logs then show the whole chain.The BCL ships static guard helpers. They throw the right exception with the right parameter name in one line.
ArgumentNullException.ThrowIfNull(x) (.NET 6).ArgumentException.ThrowIfNullOrEmpty(s) (.NET 7) and ThrowIfNullOrWhiteSpace (.NET 8).ArgumentOutOfRangeException.ThrowIfNegative, ThrowIfZero, ThrowIfGreaterThan and friends (.NET 8).ObjectDisposedException.ThrowIf(disposed, this) (.NET 7).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.
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.
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;
}
}
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");
expr argument yourself. That overrides the compiler’s value.ThrowIfNullOrWhiteSpace throws ArgumentNullException for null and ArgumentException for blanks. Catch the base type if you care about both.ThrowIfNull(customer) know the name “customer”? Its second parameter has [CallerArgumentExpression]. The compiler passes the argument text as a string at the call site.if and throw? The throw lives in a separate method the JIT will not inline. The hot method stays small, so it inlines better.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.
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;
}
}
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"));
out parameters do not work in async methods or iterators. Return a tuple or a Result there.Parse in a loop is about 100 to 1000 times slower than TryParse on bad input.int.TryParse exist when int.Parse does? Throwing is costly and bad input is common. TryParse makes the failure path cheap and explicit.out parameters? The method returns before the value is ready. A ref to a caller stack slot cannot outlive that frame safely.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.
out.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(),
};
}
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);
_ arm.Result<T>? It stops anyone outside from adding a third case. Only the nested Ok and Fail can derive from it.NotNullWhen and MemberNotNull, not with !.?., ?? and ??= remove null boilerplate. C# 14 adds a?.B = x, which skips the right side when a is null.checked, long, or a checked operator when overflow would be a bug.throw; keeps the trace and throw ex; loses it.Correct code is the first goal. Fast code is the next. Performance and Memory looks at the GC, dispose, pools and spans.