Skip to main content

Error Handling

Every program is a state machine, and every state machine must handle all use cases, which include both normal operational states and error states. The question is, how can we handle errors? In many C libraries, errors were indicated by returning functions with error codes, usually negative, and they were mixed with responses from function calls that meant something other than a success code, such as the number of bytes transferred over a communication channel. Another approach in C is setting the errno variable, which can retain a previously set value and can lead to errors. This approach proved to be unsustainable for very complex programs because programmers, even with documentation, would have trouble understanding the code. However, this approach was retained for historical reasons and because it was too difficult to redesign all libraries, even system libraries, from scratch in a different way.

Exceptions

The solution is to have distinct error types and methods of signaling errors separate from the normal program flow. Essentially, when an error occurs in a function, instead of returning a type like int for success and error, it returns a sum type (discriminated union) of either success or error. There are multiple approaches here, but one widely used in many languages is support for exceptions. An exception is an object that is thrown on error in a function and is caught in a preceding stack frame of the function call to be handled.

In many cases, when a mistake is made, for example, when trying to access a field from a null object, an error appears in the form of an exception, and the program exits. In the case of accessing data from a null reference, a NullReferenceException occurs. This is an exception that is thrown by the runtime and inherits the Exception class. Alternatively, we can throw any object that is or inherits from Exception using the throw keyword, as shown below.

throw new Exception("This is an error message!");

It is important to mention that although the Exception class has some useful properties like Message, where you have an error message, and StackTrace, where you have all the stack frames—meaning which functions were called on which line of code until the exception was caught—and the fact that you can print them, it's best to create your own exception classes with properties you provide for better error identification.

Exception Handling

Error handling is done as follows:

try
{
CallMethodWithPossibleError(); // We do something that may throw an exception.
}
catch (NullReferenceException ex)
{
// Handle the caught error.
}
catch (Exception ex)
{
// We can have a different handling for a less specific exception.
}
finally
{
// Perform cleanup if needed; this block can be omitted.
}

As seen, there are three types of code blocks:

  • try - where a code block is executed where an exception might be thrown.
  • catch - where the thrown exception is caught and can be handled. Multiple catch blocks with different exception types can exist, but they should be ordered from the most specific to the most general if they are inherited from one another. Catch blocks are processed in order until one matches the thrown exception type. try and catch are always used together, with at least one catch block required.
  • finally - is an optional block that always executes regardless of whether an exception is thrown or if a return is made in any try or catch block. This block is useful for closing resources if needed, such as open files, as it is always called. Typically, objects implementing IDisposable, like the Stream class or the IEnumerator<T> interface, have their Dispose() method called for resource deallocation.

It's important to note that in C# compared to Java, you don't have the concept of checked exceptions. In Java, if a function throws a checked exception, it must be surrounded by a try-catch block or mark the current function as throwing that checked exception. This led to many issues because although it was intended as a good practice to always handle errors, it quickly turned into a bad practice as programmers were generally annoyed by this and surrounded functions with a try-catch block just to compile without errors, ignoring the exception.

In C#, you just need to be attentive to whether exceptions are thrown in the documentation of methods and catch them where you find it appropriate. If you handle errors, it's a good idea to log them somewhere, preferably using a logger into a file or a specialized service. Don't ignore errors, as they can cause the program to malfunction, and by ignoring the error, it will be very difficult to debug it.

Using Exceptions

A possible correct use of exceptions is as follows:

// We can create error codes for different situations as in this enum.
public enum ErrorCodeEnum
{
Unknown,
NotFound,
Forbidden,
BadRequest
}

// We create our own exception that inherits from Exception with the error code.
public class CodedException : Exception
{
public ErrorCodeEnum ErrorCode { get; }

public CodedException(string message, ErrorCodeEnum errorCode = ErrorCodeEnum.Unknown) : base(message)
{
ErrorCode = errorCode;
}
}

// We further derive the exception and add the specific error code for each exception.
public class NotFoundException : CodedException
{
public NotFoundException(string message) : base(message, ErrorCodeEnum.NotFound)
{
}
}

public class ForbiddenException : CodedException
{
public ForbiddenException(string message) : base(message, ErrorCodeEnum.Forbidden)
{
}
}

public class BadRequestException : CodedException
{
public BadRequestException(string message) : base(message, ErrorCodeEnum.BadRequest)
{
}
}

As I defined the exceptions above, they offer us a lot of flexibility. We can either catch each type of exception in a separate catch block and handle each case separately, or we can handle only the base exception case, identifying the error code. We can't rely solely on the message from the Exception; it's only an informative message for the developer. The error code is more useful because, besides knowing exactly what the issue might be, we can transmit the error code to other systems that may understand it more easily than a message that could be changed by a programmer at some point.

Alternatives

Exceptions are generally useful when used where appropriate for code simplicity and are not abused. Exceptions should be thrown only when necessary to keep the code as straightforward as possible. The issue with exceptions is that while the try block executes at the same speed as an if branch, the catch block is much slower because the runtime needs to examine each stack frame until the exception can be caught, and this process is costly. Furthermore, a chain of multiple exceptions can be triggered as a result of one exception if the program flow hasn't been prepared for certain edge cases.

Moreover, in many applications, a global exception handler is created that calls the main loop of the application, and when an exception occurs, it handles each exception case globally. This solution is good, but only as a precaution to prevent the application from crashing. An alternative to exceptions is to encapsulate both responses and errors in the same object in a mutually exclusive manner.

// Declare an error message with text and an error code.
public class ErrorMessage
{
public string Message { get; }
public ErrorCodeEnum ErrorCode { get; }

public ErrorMessage(string message, ErrorCodeEnum errorCode = ErrorCodeEnum.Unknown)
{
Message = message;
ErrorCode = errorCode;
}

public override string ToString() => $"{{ {nameof(Message)} = {Message}, {nameof(ErrorCode)} = {ErrorCode} }}";
}

/* To replace exceptions, we use this class to indicate whether a function returns successfully
* or an error encapsulated in this object.
*/
public class ServiceResponse
{
public ErrorMessage? Error { get; init; }
// If there's no error, it means success.
public bool IsOk => Error == null;

/* We have several static methods to help us create objects directly while maintaining
* mutual exclusivity between error and success.
*/
public static ServiceResponse FromError(ErrorMessage? error) => new() { Error = error };
public static ServiceResponse<T> FromError<T>(ErrorMessage? error) where T : class => new() { Error = error };
public static ServiceResponse ForSuccess() => new();
public static ServiceResponse<T> ForSuccess<T>(T data) where T : class => new() { Result = data };
// We can also convert to the generic response.
public ServiceResponse ToResponse<T>(T result) where T : class => Error == null ? ForSuccess(result) : FromError(Error);

protected ServiceResponse() { }
}

/* To make the response object more versatile, we include data and make it generic.
* It needs to be a reference type to handle the null value on Result.
*/
public class ServiceResponse<T> : ServiceResponse where T : class
{
// Encapsulate the response with data in case of success.
public T? Result { get; init; }
public ServiceResponse ToResponse() => Error == null ? ForSuccess() : FromError(Error);
/* We can create other methods to make our work easier if needed, such as this map
* to handle the success or error case within the object itself, leaving a function
* to perform the conversion.
*/
public ServiceResponse<TOut> Map<TOut>(Func<T, TOut> selector) where TOut : class => Result != null ?
ForSuccess(selector(Result)) : FromError<TOut>(Error);

protected internal ServiceResponse() { }
}

With the presented example, we can easily encapsulate success and error responses and even perform transformations over them very simply, without invoking exceptions, by passing response objects. Besides not incurring the catch penalty, we can map response types to one another without adding (cyclomatic) complexity by dealing with cases using if statements. In essence, the logical flow of the program remains much cleaner. The advantage of this encapsulation approach is that it's flexible and more efficient in error cases. The disadvantage is that it adds another layer to the output types of functions, and we don't have information about exactly where in the code the error occurred unless we implement workarounds to identify the stack frame or location where the error occurred.

References