Rethinking Exceptions

Why do newer programming languages like Go, Rust and Zig eschew Exceptions, believing that they cause unpredictable control flow and hidden performance costs? Instead they encourage explicit error tracking. Even Kotlin prefers a more functional style of error handling over unchecked exceptions.

Whether you’ve been working with C# for just a few months, or for several years, have you ever stopped to consider the way that you write code to handle errors? How do other languages deal with errors and why did they choose the approach that they did? Let’s explore a few and, just maybe, it might change the way that you write and think about your C# code.

What is an exception, really?

The typical way that languages think about an Exception is as a data structure that holds information about an error condition. Instead of being returned like a regular value, the exception is thrown, meaning that execution of the current code path is interrupted, and control is transferred to an appropriate error-handling mechanism, like a catch block.

uint ParseInput(string text)
{
    try
    {
        int number = int.Parse(text);
        if (number < 0)
        {
            throw new ArgumentOutOfRangeException(
                paramName: nameof(text),
                message: "Value cannot be negative.");
        }
        return (uint)number;
    }
    catch (FormatException)
    {
        throw new ArgumentException(
            message: "Invalid input. Must be a number.",
            paramName: nameof(text));
    }
}
Side note: Why the Exception parameters appear reversed above
In the example above, notice that the “paramName” argument comes first in ArgumentOutOfRangeException, but second in ArgumentException. This may seem inconsistent at first, but each constructor overload maintains a consistent order across its other variations — ensuring that the single-argument constructor always aligns. So in that sense, it is consistent.

If our code above was published as a library, the caller’s code will compile just fine if they call the code above outside of a try / catch block… so, how does the caller know that they might need to handle the exceptions that are being thrown? (For now, let’s ignore that you should document the exceptions in the method’s xml-doc comments, and that the caller would definitely 😉 read it…)

Before we answer that, let’s take a brief look at the state of the world when C# was being designed, to better understand where it came from and what it still needs to support today. C# has been around for about two decades, and throughout that time, the Microsoft team has worked hard to ensure that existing code has continued to compile as .NET has evolved. You can still compile most C# code from the early 2000s today.

Why did C# adopt Exceptions as a language feature?

When C# was being designed in the late 1990s, Java was one of the most prominent object-oriented languages, especially in enterprise development. Microsoft sought to create a language that felt familiar to enterprise developers, making C# syntax and core features resemble features from languages like Java, C++, C, Smalltalk, and others.

They made one significantly different choice to Java when it came to Exceptions though. C# implemented unchecked exceptions (you can catch exceptions, but you don’t have to explicitly handle them or declare them on method signatures), whereas Java has checked exceptions. If you haven’t come across the idea of checked exceptions before, this is what they look like:

In Java, you must either catch the exceptions that are declared by the code that you call:

public String processInputData() {
    try {
        String text = readInputFile();
        return text;
    } catch (IOException e) {
        // handle exception
    }
}

Or, you must re-declare the same exception on your own method to let it bubble up the call stack:

public String processInputData() throws IOException {
    String text = readInputFile();
    return text;
}

This shows how Java explicitly signals to the caller that a method might throw an exception - unlike C#. The compiler makes you deal with them explicitly.

The above example is fairly simple. But in larger codebases, one obvious consequence of checked exceptions is the amount of code churn that they create. Imagine you are consuming a third party library, and then that library introduces a new type of Exception that it declares is thrown in one of the methods that you are already calling. Well, that is now a compiler error for your code unless you catch and handle it. If you can’t handle it at the call site, you must rethrow or redeclare it - causing a chain reaction of compiler errors until the exception is finally caught or suppressed.

Why C# chose to do it differently from Java

In a 2003 interview titled “The Trouble with Checked Exceptions,” Anders Hejlsberg, the lead architect of C#, discussed the rationale behind C#’s approach to exception handling. He highlighted concerns regarding versioning and scalability associated with checked exceptions, which influenced the decision to exclude them from C#.

The broader debate between checked and unchecked exceptions tends to cover these key points (among others):

  • Error Handling Complexity: Checked exceptions can lead to cluttered code, as developers must catch or declare them, even when meaningful recovery actions are not possible.

  • API Design Philosophy: Unchecked exceptions allow for cleaner APIs by not enforcing exception handling at every level, thereby promoting a more streamlined and readable codebase.

  • Developer Responsibility: Unchecked exceptions place the onus on developers to handle exceptions appropriately, offering flexibility to manage errors where it makes the most sense in the application flow.

Other options that C# could have considered?

Back to those modern languages that I mentioned… the new languages that have chosen a different approach to error handling.

Here’s how that previous code might look in Go:

func processInputData() (string, error) {
    text, err := readInputFile()
    if err != nil {
        // Return an empty string and wrap the error
        return "", fmt.Errorf("failed to process input: %w", err)
    }
    return text, nil // Success
}

In Go, functions have multiple return values, and by convention errors are returned as the last return value. Errors in Go are “just data” (remember that I wrote earlier that Exceptions are just “a data structure that holds information about an error condition”?) - so errors and exceptions are fundamentally just error data. What is different between errors and exceptions is in how they are propagated. When you call a function in Go, you don’t know if the return value is useful or just an empty value, until you check to see if an error has been returned or not.

Go programmer bugbear: if err != nil
Probably the most frequent complaint from Go programmers is about how often they need to type if err != nil because so many functions return an error value. It becomes tedious boilerplate, but if any kind of tedium could be called a good thing, this is good because it makes programmers deal with an error as soon as possible. It is explicit. No surprises!

The biggest difference when comparing Go’s “error as a value” pattern with C# exceptions is that there is no hidden control flow. If an error occurs in Go, the function that you just called still returns and then you just check what you got back. As the caller, you are still in full control and can decide what to call next, depending on whether you received an error or not.

Because the function declares the error in its function signature, you are fully aware that an error could possibly be returned:

func processInputData() (string, error)

Checking the error immediately causes you to consider how to deal with it as close to where it occurred as possible, in most cases, literally on the next line of code!

text, err := readInputFile()
if err != nil {
    return "", fmt.Errorf("failed to process input: %w", err)
}

Languages that throw exceptions have multiple control flow paths - and this adds to cognitive load. Your happy-path code flows down one set of method calls, but if an exception is thrown, that flows back up a different route, through a series of catch blocks. The catch block in any given method might be many lines away from the line of code that caused the exception that is being handled in the catch block! In Go, you would typically handle the error condition immediately next to the code that gives rise to the error condition.

FeatureC# (Exceptions)Go (Error as a Value)
Error PropagationImplicit (stack unwinding)Explicit (if err != nil)
Control FlowMultiple paths (try/catch)Linear (error is returned)
PerformanceOverhead from exception handlingNo performance penalty
ReadabilityErrors handled far from sourceErrors handled at the call site

“exceptional” or just errors?

From my experience, most of the exceptions that we throw within application code in C# are actually just conveying error information. They aren’t “exceptional” cases such as OutOfMemoryException which would be a true, unforeseen, error that cannot be recovered - in other words, exceptional.

If we look at a sample of C# exceptions from three main categories, it starts to appear that there are some kinds of exception that can be anticipated, and therefore might be treated as errors that could be returned instead of thrown.

1. Application Exceptions (common anticipated assertion and logic errors)

  • Exception (System.Exception)
    • IOException (System.IO.IOException)
    • TimeoutException (System.TimeoutException)
    • FileNotFoundException (System.IO.FileNotFoundException)
    • UnauthorizedAccessException (System.UnauthorizedAccessException)
    • InvalidOperationException (System.InvalidOperationException)
    • CustomException (User-defined)

Application Exceptions can be anticipated during programming because they indicate logical error conditions, or assertions. (Not to be confused with the System.ApplicationException class that should not be used.)

It is frustrating to me that the general advice is to derive custom exceptions from System.Exception, because that base class is also shared with System.SystemException. I think the original intention was for all application exceptions to derive from System.ApplicationException but inconsistencies in the framework led to that being impractical. So create your own base class and derive your custom exceptions from that instead.

DO: derive a custom exception base class from `System.Exception`
Custom exceptions should be derived from the System.Exception class to differentiate them from other kinds of unanticipated runtime errors derived from System.SystemException.

These types of exceptions are the ones that you might throw from a constructor in your core business logic domain. (C# cannot return values from a constructor, so there is no way to follow the error-return pattern). If you really wanted to avoid throwing these kinds of exceptions from a constructor, and have something more like Go’s “error as a value” approach, then one option is to make private / internal constructors and use a factory method to construct the core domain entity instead. The factory method could contain the validation rules and invariants and return error values instead of throwing an exception.

using System;
using ErrorOr; // Assumes ErrorOr or similar library, or custom Result<T> type

public class BankAccount
{
    public int AccountNumber { get; }
    public decimal Balance { get; private set; }

    private BankAccount(int accountNumber, decimal balance)
    {
        AccountNumber = accountNumber;
        Balance = balance;
    }

    public static ErrorOr<BankAccount> Create(int accountNumber, decimal initialDeposit)
    {
        if (accountNumber <= 0)
            return Error.Validation("InvalidAccountNumber", "Account number must be positive.");

        if (initialDeposit < 0)
            return Error.Validation("NegativeInitialDeposit", "Initial deposit cannot be negative.");

        return new BankAccount(accountNumber, initialDeposit);
    }

    public ErrorOr<Success> Withdraw(decimal amount)
    {
        if (amount <= 0)
            return Error.Validation("InvalidWithdrawal", "Withdrawal amount must be positive.");

        if (Balance - amount < 0)
            return Error.Validation("InsufficientFunds", "Insufficient balance for withdrawal.");

        Balance -= amount;
        return Result.Success;
    }
}

The example is fairly simple, but focus more on the error vs exception approach. If you really want to avoid using a third party library in the domain code, C# may soon get its own Type Union feature and then you can use built-in language features to accomplish the same.

2. Runtime Exceptions (commonly encountered)

  • SystemException (System.SystemException)
    • NullReferenceException (System.NullReferenceException)
    • IndexOutOfRangeException (System.IndexOutOfRangeException)
    • ArithmeticException (System.ArithmeticException)
      • DivideByZeroException (System.DivideByZeroException)
      • OverflowException (System.OverflowException)
    • ArgumentException (System.ArgumentException)
      • ArgumentNullException (System.ArgumentNullException)
      • ArgumentOutOfRangeException (System.ArgumentOutOfRangeException)
    • InvalidCastException (System.InvalidCastException)
    • FormatException (System.FormatException)
    • KeyNotFoundException (System.Collections.Generic.KeyNotFoundException)

Runtime Exceptions are lower-level error conditions that indicate that the program logic has got into an invalid state and this would generally be unintentional, and therefore unanticipated. To give you a sense of how seriously we should treat these kinds of exceptions…

… in Go, the runtime will panic for these kinds problems. A panic will abort the current processing and unwind the stack, eventually crashing the entire process, unless it is recovered to prevent the crash. These types of errors come as close to exception-like behaviour in Go as it gets, because unwinding the stack after a panic sounds a lot like the way exceptions work. But the difference is that in Go, this is not normal. It is exceptional in the true sense of the word. If only it were the same in C#!

Be careful with all types of SystemException

Fail as fast and gracefully as possible. You cannot recover the current state. You might temporarily catch the exception to clean up some resources but then you should rethrow it.

The documentation for SystemException says you should never throw a SystemException in your own code. Nor should you attempt to handle a SystemException, other than to briefly release resources, before rethrowing.

3. Unrecoverable Exceptions (less common and usually fatal)

  • SystemException (System.SystemException)
    • StackOverflowException (System.StackOverflowException)
    • OutOfMemoryException (System.OutOfMemoryException)
    • AccessViolationException (System.AccessViolationException)
    • ThreadAbortException (System.Threading.ThreadAbortException)

A subset of SystemException but with potentially wider implications than just the current operation. For example, the process might survive a DivideByZeroException if caught, but a StackOverflowException should immediately terminate the entire process. Whilst you might theoretically catch an OutOfMemoryException, how sure can you be about the stability and future state of the rest of the process?

Some SystemExceptions cannot be recovered
Some types of SystemException indicate that the runtime may have got into an invalid or unrecoverable state. Be wary of attempting to proceed because the process or memory could be in a corrupt or invalid state, making things unpredictable and inconsistent. This is a situation where I would consider panicking and terminating the entire program.

Conclusion

We’ve explored that exceptions in C# help to declutter code but come with trade-offs like hidden control flow, runtime performance costs, and implicit error propagation. This is why modern languages like Go have opted for explicit error handling instead.

Maybe more C# programmers will start to change their approach to error handling and start:

  • Using error-return patterns (e.g., Result<T> in domain logic).
  • Avoiding exceptions for expected conditions (e.g., validation errors).
  • Keeping exceptions truly exceptional (e.g., SystemException).

Learning another language like Go can challenge our assumptions and make us write better, simpler code - even in C#.

What do you think? Would you prefer explicit errors over exceptions in future versions of C#? Maybe C#’s Type Unions will take us quite a lot of the way there…