Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Ferrum is a .NET (F# + C#) library for working with the dynamic error interface Ferrum.IError. Unlike exceptions, errors are value-orientated and designed to be stored and passed multiple times.

The first library goal is to provide basic error value interfaces that can be used as a common dynamic interface for error implementation types in the ".NET" ecosystem.

The second library goal is to provide basic ways to create and compose types that implement the common error interface.

Packages

Main packages

PackageDescription
Ferrum.AbstractionsProviding interfaces and extensions to make working with them easier. Useful for defining domain-specific error types
FerrumDynamic (based on strings) ways to create and compose errors. Useful for applications that do nat care about error details.

Integration packages

PackageDescription
Ferrum.FSharpProvides integration with the F# including Result<'TValue, 'TError> type.

Additional packages

PackageDescription
Ferrum.GraphvizExport error structure to graphviz string. Useful for aggregated errors and just for fun.

User Guide

In this section, you will learn how to:

  • Create and compose errors
  • Extract information from errors
  • Format errors

IError Interface

Ferrum.IError is the interface of a basic error value, typically used in result types of the form Result<TValue, TError>.

F# supports the Result type in its standard library, and C# has various community libraries such as DotNext (Their use is optional, but wrappers improve usability).

IError contains a string error message IError.Message (expected to be a single line, without trailing punctuation) and can provide low-level error via the IError.InnerError property. To display errors as string, see Error Formatting.

Basic Creating and Composing

You can create "non discriminated" errors using the types provided by Ferrum.

To create an error that contains only message, use MessageError:

IError error = new MessageError("Message based error");

Debug.Assert(error.Message == "Message based error");
Debug.Assert(error.InnerError == null);

Add high-level context to errors to understand where and how low-level errors happen. Use ContextError class or error.Context(string) extension:

IError cause = new MessageError("Main error");
IError error = new ContextError("Context error", cause);

Debug.Assert(error.Message == "Context error");
Debug.Assert(error.InnerError!.Message == "Main error");

All types that are errors could implement IError, but this is not always possible. AnyError.OfValue converts any type to an IError. By default, the result of ToString is used for the error message, but some types converted in a more idiomatic way. You should not depend on the specific behavior of AnyError.OfValue.

(string, int, int) notIErrorError = ("invalid token", 12, 48);
IError error = AnyError.OfValue(notIErrorError);

Assert.Equal("(invalid token, 12, 48)", error.Message);

The error.Chain() extension gets the entire chain of errors as an enumerator, and error.GetRoot() gets the root error (the last error in the chain).

Error Aggregating

Errors that may contain multiple low-level errors should implement the IAggregateError interface.

It extends IError with an InnerErrors property, which unlike InnerError is a collection of errors, similar to InnerExceptions being the property of AggregateException.

All IAggregateError implementations should use the first element of InnerErrors as the value of InnerError, or null if InnerErrors is empty or null.

Unlike exceptions, the value of the InnerErrors can be null. It means that the error is not aggregated, but InnerError may be not null. To avoid handling it manually, use the GetInnerErrors() extension, which always returns a not null IError collection.

For manual aggregation, use the AggregateError implementation.

Usage example:

IAggregateError aggError = new AggregateError(
    "Aggregate error",
    new MessageError("Inner error 0"),
    new MessageError("Inner error 1"),
    new MessageError("Inner error 2")
);

IError[] innerErrors = aggError.InnerErrors!.ToArray();
Assert.Equal("Aggregate error", aggError.Message);
Assert.Equal("Inner error 0", innerErrors[0].Message);
Assert.Equal("Inner error 1", innerErrors[1].Message);
Assert.Equal("Inner error 2", innerErrors[2].Message);

GetInnerErrors example:

IError aggError = new AggregateError(
    "Aggregate error",
    new MessageError("Inner error 0"),
    new MessageError("Inner error 1"),
    new MessageError("Inner error 2")
);
IError notAggError = new MessageError("Not aggregate inner").Context("Not aggregate error");
IError innerEmptyError = new MessageError("Not inner errors");

Assert.Equal(3, aggError.GetInnerErrors().Count);
Assert.Equal(1, notAggError.GetInnerErrors().Count);
Assert.Equal(0, innerEmptyError.GetInnerErrors().Count);

Error Tracing

Errors implementing ITracedError can have a stack trace in the StackTrace property. To get a stack trace for any error without explicit type checks, use the GetStackTrace extension.

Stack traces are used in DetailedErrorFormatter and DiagnosticErrorFormatter. See Error Formatting for more details.

Most of the errors built in Ferrum have stack trace variants. They collect the stack trace when they are created.

Without stack traceWith stack trace
MessageErrorTracedMessageError
ContextErrorTracedContextError
AggregateErrorTracedAggregateError

You may want to capture stack traces or not depending on environment variables or build type (release/debug). Ferrum does not currently provide default solutions for these scenarios. You can declare your own functions for these purposes.

Capturing stack traces takes time, so use this feature carefully. If the error value returned by a function is intended to be used for branches, and not just returned to the caller, then capturing stack traces can have an impact on performance. In such use cases, the recommendations for using errors with stack traces are the same as for thrown exceptions.

If you implement an error type, you don't need to worry about your error storing a stack trace. Domain errors are weakly dependent on a stack trace. They are usually either handled in code without needing to view the trace, or upcasted to IError and wrapped in a context error that may contain a stack trace. For these reasons, Ferrum's design does not focus on designing errors with stack traces in user space.

But you also shouldn't exclude the possibility of implementing the ITracedError interface if you think it will be useful in a particular case.

Error Formatting

Errors can implement ToString or IFormattable for formatting specific to them. But to keep error formatting consistent, it is recommended to use error formatters (IErrorFormatter interface). In specific situations, this may reduce the amount of information about error, but allows errors to be formatted more predictable.

Default formatters is MessageErrorFormatter, SummaryErrorFormatter, DetailedErrorFormatter, DiagnosticErrorFormatter and NullErrorFormatter. All formatters have an Instance property with them instances.

You can also get the formatter by format string or verbosity level using the functions ErrorFormatter.ByFormat(string) and ErrorFormatter.ByLevel(int) respectively. For each format string (except NullErrorFormatter) there is a corresponding extension method for the IError type, using which you can quickly format the error.

FormatterFormatLevelMethodMessagesLinesStack trace
MessageErrorFormatterM/L11FormatMFinalSingleNo
SummaryErrorFormatterS/L22FormatSAllSingleNo
DetailedErrorFormatterD/L33FormatDAllMultiFor most depth error
DiagnosticErrorFormatterX/L44FormatXAllMultiFor all traced errors
NullErrorFormatterN/L00----

So the error can be formatted in a variety of ways:

SummaryErrorFormatter.Instance.Format(error);
error.Format(SummaryErrorFormatter.Instance);
ErrorFormatter.ByFormat("S").Format(error);
error.Format(ErrorFormatter.ByFormat("S"));
ErrorFormatter.ByLevel(2).Format(error);
error.Format(ErrorFormatter.ByLevel(2));
error.FormatS();
$"{error:S}"; // Specific for Ferrum buildin errors

All examples are equivalent (except string interpolation). Hope everyone enjoys the factorial examples!

Errors provided by Ferrum are IFormattable, but other errors may not be. Therefore, it is recommended to explicitly use the formatters. If you implement IError, you can inherit from the BaseError type if possible. It provides IFormattable and ToString implementations for IError by default. (Default formatter is SummaryErrorFormatter)

(In version v0.4 not implemented FormatProvider for errors. I don't even understand if it's necessary)

Format result examples:

MessageErrorFormatter:

User not created


SummaryErrorFormatter:

User not created: DB unreachable: I/O error


DetailedErrorFormatter or DiagnosticErrorFormatter without tracing:

[0] Error: User not created
[1] Cause: DB unreachable
[2] Cause: I/O error


DetailedErrorFormatter with tracing:

[0] Error: User not created
[1] Cause: DB unreachable
[2] Cause: I/O error
Trace [1]:
   at ...


DiagnosticErrorFormatter with tracing:

[0] Error: User not created
[1] Cause: DB unreachable
[2] Cause: I/O error
Trace [0]:
   at ...
Trace [1]:
   at ...


Note that DetailedErrorFormatter and DiagnosticErrorFormatter end the formatted string with a line break.

Exception Interop

Convert exceptions to errors

For convert Exception to IError used extension member Exception.ToError which has the following behavior:

  • If the exception has inner exception, then the returned error has inner error based on this inner exception
  • If the exception is aggregated, then the returned error is also aggregated
  • If the exception has stack trace, then the returned error has stack trace

Simple example:

Exception ex = new Exception("Simple exception");
IError error = ex.ToError();

Assert.Equal("Simple exception", error.Message);
Assert.Null(error.InnerError);

With inner exception:

Exception ex = new Exception("Final exception", new Exception("Inner exception"));
IError error = ex.ToError();

Assert.Equal("Final exception", error.Message);
Assert.Equal("Inner exception", error.InnerError!.Message);

With aggregation:

Exception ex = new AggregateException(
    "Aggregate exception",
    new Exception("Inner exception 0"),
    new Exception("Inner exception 1")
);
IError error = ex.ToError();
IError[] innerErrors = error.GetInnerErrors().ToArray();

Assert.Equal("Aggregate exception (Inner 0) (Inner 1)", error.Message);
Assert.Equal("Inner exception 0", innerErrors[0].Message);
Assert.Equal("Inner exception 1", innerErrors[1].Message);

With stack trace:

IError error;
try
{
    throw new Exception("Traced exception");
}
catch (Exception ex)
{
    error = ex.ToError();
}

Assert.Equal("Exception msg", error.Message);
Assert.Null(error.InnerError);
Assert.NotNull(error.GetStackTrace());

Converting errors to exceptions

For convert IError to Exception used extension member IError.ToException which has the following behavior:

  • If the error has inner error, then the returned exception has inner exception based on this inner error
  • If the error is aggregated, then the returned exception is also aggregated
  • The stack trace of the returned exception is not overwritten by the error stack trace
The stack trace of the returned exception is not overwritten by the error stack trace.

Simple example:

IError error = new MessageError("Error msg");
Exception ex = error.ToException();

Assert.Equal("Error msg", ex.Message);
Assert.Null(ex.InnerException);

With inner errors:

IError error = new ContextError("Error msg", new MessageError("Inner msg"));
Exception ex = error.ToException();

Assert.Equal("Error msg", ex.Message);
Assert.Equal("Inner msg", ex.InnerException!.Message);

With aggregation:

IError error = new AggregateError(
    "Aggregate error",
    new MessageError("Inner 0"),
    new MessageError("Inner 1")
);
Exception ex = error.ToException();
Exception[] innerEx = ((AggregateException) ex).InnerExceptions.ToArray();

Assert.Equal("Aggregate error (Inner 0) (Inner 1)", ex.Message);
Assert.Equal("Inner 0", innerEx[0].Message);
Assert.Equal("Inner 1", innerEx[1].Message);

With stack trace:

IError error = new TracedMessageError("Traced error msg");
Assert.NotNull(error.GetStackTrace());
Exception ex = error.ToException();

Assert.Null(ex.StackTrace); // Error stack trace does not mirror to exception

Ferrum for F#

The Ferrum.FSharp package provides Error and Result modules with more F#-ideomatic wrappers of existing C# implementations. Nullable values are replaced to optional values. The Result module contains additional utility functions for creating and converting values of the form Result<'a, IError>. It also contains the alias type Result<'a> = Result<'a, IError>.

FerrumFerrum.FSharp
new MessageError(msg)Error.message msg / Result.message msg
new ContextError(msg, inner) / inner.Context(msg)Error.context msg inner / Result.context msg inner
new AggregateError(msg, inners)Error.aggregate msg / Result.aggregate
AnyError.OfValue(value)Error.box value / Result.boxError
error.MessageError.getMessage error
error.InnerErrorError.getInnerError error
error.GetInnerErrors()Error.getInnerErrors error
error.GetStackTrace()Error.getStackTrace
error.Chain()Error.chain
error.GetRoot()Error.getRoot
error.ToException()Error.toException
exception.ToError()Error.ofException
error.Format(formatter)Error.format formatter error
error.Format(format)Error.formatBy format error
error.Format(level)Error.formatL level error
error.FormatM()Error.formatM error
error.FormatS()Error.formatS error
error.FormatD()Error.formatD error
error.FormatX()Error.formatX error
......

Creating Domain Error Types

How a domain error should be implemented is not a trivial question. The implementation of IError has nothing to do with it, since domain errors are defined to be handled in the code itself without type erasure. Examples of errors implementations are presented here, but the "how" answer cannot be given.

C# Examples

// EnumWithContextIoErrorExample

public enum IoErrorKind
{
    FileNotFound,
    PermissionDenied,
}

public class IoError(IoErrorKind kind, string path) : IError
{
    public IoErrorKind Kind { get; } = kind;
    public string Path { get; } = path;

    public string Message
    {
        get
        {
            return Kind switch
            {
                IoErrorKind.FileNotFound => $"File '{Path}' not found",
                IoErrorKind.PermissionDenied => $"File '{Path}' can't be opened",
                _ => throw new UnreachableException()
            };
        }
    }

    public IError? InnerError => null;
}
// EnumOnlyIoErrorExample

public enum IoErrorKind
{
    FileNotFound,
    PermissionDenied,
}

public class IoError(IoErrorKind kind) : IError
{
    public IoErrorKind Kind { get; } = kind;

    public string Message
    {
        get
        {
            return Kind switch
            {
                IoErrorKind.FileNotFound => $"File not found",
                IoErrorKind.PermissionDenied => $"File can't be opened",
                _ => throw new UnreachableException()
            };
        }
    }

    public IError? InnerError => null;
}
// InheritanceIoErrorExample

public abstract class IoError : IError
{
    public abstract string Message { get; }
    public abstract IError? InnerError { get; }
}

public class FileNotFoundIoError() : IoError
{
    public override string Message => "File not found";
    public override IError? InnerError => null;
}

public class PermissionDeniedIoError() : IoError
{
    public override string Message => "Permission denied";
    public override IError? InnerError => null;
}

F# Examples

// EnumWithContextIoErrorExample =

[<RequireQualifiedAccess>]
type IoErrorKind = FileNotFound | PermissionDenied

type IoError =
    { Kind: IoErrorKind; Path: string }
    interface IError with
        member this.Message =
            match this.Kind with
            | IoErrorKind.FileNotFound -> $"File '{this.Path}' not found"
            | IoErrorKind.PermissionDenied -> $"File '{this.Path}' can't be opened"
        member this.InnerError = null
// EnumOnlyIoErrorExample =

[<RequireQualifiedAccess>]
type IoError =
    | FileNotFound
    | PermissionDenied
    interface IError with
        member this.Message =
            match this with
            | IoError.FileNotFound -> "File not found"
            | IoError.PermissionDenied -> "File can't be opened"
        member this.InnerError = null
// HierarchicalExample =

type SimpleError =
    | SimpleCase
    interface IError with
        member this.Message =
            match this with
            | SimpleCase -> "Some simple error case"
        member this.InnerError =
            null

type ComplexError =
    | Source of SimpleError
    | SomeError
    interface IError with
        member this.Message =
            match this with
            | Source _ -> "Error caused by simple error source"
            | SomeError -> "Some complex error case"
        member this.InnerError =
            match this with
            | Source simpleError -> simpleError
            | SomeError -> null

I recommend the following thoughts from the Rust ecosystem if the language barrier is not an issue for you. Error design in Rust

Additional links

Design Notes

This section describes why Ferrum is designed the way it is and not otherwise. You can skip it if you are not interested in the details or chaotic thoughts.

IAggregateError Design

Message not like in AggregateException

Aggregate errors should not include inner errors messages in their message, as AggregateException does. Let formatters and other display tools do their job.

The approach with a message like AggregateException certainly signals aggregation quite clearly. But it is simply not consistent. AggregateException can be created with one inner exception in the constructor, but the message will be different from the same constructor with a regular Exception. And the mapping tools cannot compensate for this behavior without questionable assumptions.

Nullable InnerErrors

This is useful if the error can be dynamically aggregated or not, and it is challenging to make two types with different interface implementations (for example, a json serializable error that can represent inner errors both as an object and as an array). It also avoids allocating an array of one element if nothing was actually aggregated, although this must be controlled explicitly and this plays against GetInnerErrors (because it is forced to allocate an array of one element).

Super-error aggregator or error-list directly?

IAggregateError is an error with a list of inner errors, not an error that is a list of errors directly. This has two motivations:

  • If the code returns a list of errors, then the calling code can rarely use only one of them while maintaining logical correctness.
  • The error-list must mimic a single error to cast to IError.

The error-list can mimic:

  1. By combining all messages into a common string and all inner errors into one big list.
  2. By proxying the implementation of IError itself to one of the inner errors of itself as an aggregated error.

The problem (1) is the ambiguity of such a union. The locality of messages is lost and can only be restored by type analysis. The computational complexity of the union is high.

The problem (2) is that if the error was upcast to IError (which is one of the goals of the library), then one error from the list cannot signal aggregation.

If we aggregate the list of errors into a super-error, its message can indicate the scope of the aggregation.