Exceptions, Disposal and Failing on Purpose
C# has no checked exceptions. Nothing in a signature tells a caller what might be thrown, which makes what you throw - and what you document - a design decision rather than a compiler-enforced one.
The distinction that matters is the same as everywhere: is this a condition the caller could reasonably expect, or a mistake in the code?
// Expected: the input was wrong, and the caller can react.
try
{
int.Parse("twelve");
}
catch (FormatException e)
{
Console.WriteLine($"expected: {e.Message}");
}
// A mistake: catching this hides a bug rather than handling a situation.
try
{
int[] values = [1, 2, 3];
Console.WriteLine(values[7]);
}
catch (IndexOutOfRangeException)
{
Console.WriteLine("a bug, not a condition");
}
// The most common of all, and .NET names the expression that was null.
try
{
string? nothing = null;
Console.WriteLine(nothing!.Length);
}
catch (NullReferenceException e)
{
Console.WriteLine($"NullReferenceException: {e.Message}");
}
catch, when, and finally
string[] keys = ["ok", "missing", ""]; // the collection expression needs a
// target type, so foreach over a
foreach (string key in keys) // bare [...] does not compile
{
try
{
Console.WriteLine(Load(key));
}
catch (ArgumentException e) when (e.ParamName == "key")
{
// `when` filters WITHOUT unwinding the stack, so a filter that does not
// match leaves the exception to travel on with its original context.
Console.WriteLine($" bad argument: {e.Message.Split(" (")[0]}");
}
catch (KeyNotFoundException)
{
Console.WriteLine(" no such record");
}
finally
{
Console.WriteLine($" ...done with \"{key}\"");
}
}
static string Load(string key) => key switch
{
"" => throw new ArgumentException("must not be empty", nameof(key)),
"missing" => throw new KeyNotFoundException(),
_ => $"value for {key}",
};
Order matters: the first matching catch wins, so specific types go above
general ones. A bare catch at the end catches everything, including bugs.
using, and the resources it closes for you
Anything implementing IDisposable belongs in a using. The compiler writes
the try/finally so the resource is released even when the body throws.
using (var outer = new Connection("db"))
{
outer.Use();
try
{
using var inner = new Connection("cache"); // declaration form: closes
inner.Use(); // at the end of the scope
throw new InvalidOperationException("something went wrong");
}
catch (InvalidOperationException e)
{
Console.WriteLine($"caught: {e.Message}");
}
}
sealed class Connection(string name) : IDisposable
{
public void Use() => Console.WriteLine($"using {name}");
public void Dispose() => Console.WriteLine($"closed {name}");
}
using var without braces is the modern form: it disposes at the end of the
enclosing scope, and saves a level of indentation.
Your own exception types
foreach ((string email, int age) in new[] { ("ada@example.com", 36), ("nope", 30) })
{
try
{
Console.WriteLine($"{new Registration(email, age)} accepted");
}
catch (ValidationException e)
{
Console.WriteLine($"{email} rejected on {e.Field}: {e.Message}");
}
}
sealed class ValidationException(string field, string reason)
: Exception($"{field} {reason}")
{
public string Field { get; } = field;
}
// A record has no "compact constructor": validation goes in the property
// initialisers, which run in declaration order before the object is usable.
record Registration(string Email, int Age)
{
public string Email { get; } = Email.Contains('@')
? Email
: throw new ValidationException(nameof(Email), "must contain @");
public int Age { get; } = Age >= 13
? Age
: throw new ValidationException(nameof(Age), "must be at least 13");
}
Rethrow with throw;, never throw e;
try
{
Outer();
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
Console.WriteLine($" caused by: {e.InnerException?.Message}");
}
static void Outer()
{
try
{
Inner();
}
catch (FormatException e)
{
// Wrapping: keep the original as InnerException so the trace survives.
throw new InvalidOperationException("could not load the configuration", e);
}
}
static void Inner() => int.Parse("not a number");
Inside a catch, bare throw; rethrows with the original stack trace intact.
throw e; resets it to this line - which discards the only evidence of where
the failure actually happened.
Often the answer is not to throw
.NET's own library shows both shapes: int.Parse throws, int.TryParse returns
a bool. An outcome the caller expects is a value.
string[] inputs = ["10", "twelve", "30"];
int total = 0;
foreach (string input in inputs)
{
if (int.TryParse(input, out int value)) total += value;
}
Console.WriteLine(total);
// The same thing in LINQ, and without an exception per bad row.
int sum = inputs
.Select(text => (ok: int.TryParse(text, out int n), value: n))
.Where(r => r.ok)
.Sum(r => r.value);
Console.WriteLine(sum);
Exceptions are also expensive - constructing one captures a stack trace. One per bad row in a large import is a measurable cost as well as a confusing design.
What to take away
- No checked exceptions: what you throw is documentation, not a signature.
whenfilters without unwinding, so the context survives a non-match.using/using varreleases resources even when the body throws.- Carry the data a caller needs on your exception type, not just in a message.
throw;preserves the stack trace;throw e;destroys it.- Prefer the
TryParseshape for an outcome the caller expects.