Exceptions, Disposal and Failing on Purpose

Article · 13:00

C# has no checked exceptions, so nothing in a signature tells a caller what might be thrown and what you throw becomes a design decision. Exception filters, using and IDisposable, how a rethrow loses the stack trace, and the TryParse shape.

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.
  • when filters without unwinding, so the context survives a non-match.
  • using/using var releases 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 TryParse shape for an outcome the caller expects.