Concept Subspace :: sus :: error :: Error

template <class T>
concept Error
requires
requires(const T& t) {
  // Required methods.
  {
    // auto display(const T&) -> std::string
    ErrorImpl<std::remove_const_t<T>>::display(t)
  } -> std::same_as<std::string>;
  // Optional methods.
  requires requires {
    // auto source(const T&) -> Option<const DynError&>
    {
      ErrorImpl<std::remove_const_t<T>>::source(t)
    } -> std::same_as<sus::Option<const DynError&>>;
  } || !requires {
    { ErrorImpl<std::remove_const_t<T>>::source(t) };
  };
}

Error is a concept representing the basic expectations for error values, i.e., values of type E in Result<T, E>.

Errors must describe themselves through a display method. Error messages are typically concise lowercase sentences without trailing punctuation:

auto err = u32::try_from(-1).unwrap_err();
sus_check(fmt::to_string(err) == "out of bounds");

Implementing Error

To make an Error type, specialize ErrorImpl<T> for the error type T and implement the required method:

  • static auto display(const T&) -> std::string: An error message describing the error. fmtlib support is provided for all error types through a blanket implementation that uses display. Error messages are typically concise lowercase sentences without trailing punctuation:
    auto err = u32::try_from(-1).unwrap_err();
    sus_check(fmt::to_string(err) == "out of bounds");
    

The following method may optionally also be provided:

  • static auto source(const T&) -> sus::Option<sus::error::DynError>: Optional information about the cause of the error. A simple implementation would just return sus::none().

    source is generally used when errors cross "abstraction boundaries". If one module must report an error that is caused by an error from a lower-level module, it can allow accessing that error via source. This makes it possible for the high-level module to provide its own errors while also revealing some of the implementation for debugging.

    The Error object returned by source must be type-erased as a DynError. See Type erasure for more.

Using Error

To use an Error type, use:

All Error types are implicitly formattable with fmtlib, such as with fmt::format("ERROR: {}", error). Because we can't provide a blanket implementation of operator<< they are not implicitly streamable, and fmt::to_string(error) can be used to stream error.

Avoid instantiating ErrorImpl<T> yourself to call the static methods.

Type erasure

Working with Error types directly requires templates that knows the precise type. At times this is convenient but holding different kinds of errors in a Result requires a single type, as does passing error types through virtual methods or dylib ABI boundaries.

See DynConcept for more on type erasure of Error types.

Opaque erasure

When an application just wants to return an error without exposing the actual type behind it, use the DynError type. This can be useful for storing errors returned from other layers of an application or external libraries to be given as the error source. Or when you don't want or need the receivers of the error to introspect inside them.

To do this, return Result<T, Box<DynError>>. The Box satisfies Into<Error, Box<DynError>> which means the result can be constructed by sus::err(sus::into(error)) for any error that satisfies Error.

This is similar to Box<dyn Error> when working with the Rust Error trait. However with DynError, the error type can be printed/displayed but no further information can be extracted from the error. Nonetheless this is commonly sufficient, providing even more information than the prolific bool return value of legacy C/C++ code.

To store an error in order to report it as the source of another error, the first error must be type-erased as a DynError, usually in Box<DynError>, to be returned from the source function in the Error implementation.

Note that both DynError and Box<DynError> satisfy the Error concept.

Recovering the full error type

If an application wants to be able to recover the specific type of error, and structured data from within it, there are two choices:

  • Make all errors a subclass of a single class which we'll call AppError. It should satisfy Error and it can do so through virtual methods if needed. Then, return Result<T, Box<AppError>> to have the AppError subclass placed on the heap and type-erased to the base class, and Result will display the error's description if it panics.

    This restricts errors to being class (or struct) types.

    To get at the specific error type, use runtime-type-information (RTTI) to downcast, or provide a (TODO: Downcast) implementation from AppError to its subclasses.

  • Place all application error types into a single sum type such as std::variant or Choice. Then implement Error for your fully resolved sum type.

    This allows each error inside the sum type to be any type at all, and avoids type erasure, using type composition instead.

Examples

An enum error type:

enum class ErrorReason {
  SomeReason
};

template <>
struct sus::error::ErrorImpl<ErrorReason>  {
  constexpr static std::string display(const ErrorReason& self) noexcept {
    switch (self) {
      case ErrorReason::SomeReason: return "we saw SomeReason happen";
    }
    sus_unreachable();
  }
};

static_assert(sus::error::error_display(ErrorReason::SomeReason) ==
              "we saw SomeReason happen");

An struct error type, which is backed by a string:

struct ErrorString final {
  std::string reason;
};

template <>
struct sus::error::ErrorImpl<ErrorString>  {
  constexpr static std::string display(const ErrorString& self) noexcept {
    return sus::clone(self.reason);
  }
};

static_assert(sus::error::error_display(ErrorString("oops")) == "oops");

An example function that returns a Result<void, Box<DynError>>, allowing it to return any error type:

auto f = [](i32 i) -> sus::result::Result<void, sus::Box<sus::error::DynError>> {
  if (i > 10) return sus::err(sus::into(ErrorReason::SomeReason));
  if (i < -10) return sus::err(sus::into(ErrorString("too low")));
  return sus::ok();
};

sus_check(fmt::format("{}", f(20)) == "Err(we saw SomeReason happen)");
sus_check(fmt::format("{}", f(-20)) == "Err(too low)");
sus_check(fmt::format("{}", f(0)) == "Ok(<void>)");

An example error that reports a source:

struct SuperErrorSideKick {};

struct SuperError {
  sus::Box<sus::error::DynError> source;
};

template <>
struct sus::error::ErrorImpl<SuperError> {
  constexpr static std::string display(const SuperError&) noexcept {
    return "SuperError is here!";
  }
  constexpr static sus::Option<const DynError&> source(
      const SuperError& self) noexcept {
    return sus::some(*self.source);
  }
};
static_assert(sus::error::Error<SuperError>);

template <>
struct sus::error::ErrorImpl<SuperErrorSideKick> {
  constexpr static std::string display(const SuperErrorSideKick&) noexcept
  {
    return "SuperErrorSideKick is here!";
  }
};
static_assert(sus::error::Error<SuperErrorSideKick>);

auto get_super_error = []() -> sus::result::Result<void, SuperError> {
  return sus::err(SuperError{.source = sus::into(SuperErrorSideKick())});
};

if (auto r = get_super_error(); r.is_err()) {
  auto& e = r.as_err();
  sus_check(fmt::format("Error: {}", e) == "Error: SuperError is here!");
  sus_check(
      fmt::format("Caused by: {}", sus::error::error_source(e).unwrap())
      == "Caused by: SuperErrorSideKick is here!");
}

An example of a custom error type hierarchy, which can allow for recovering the full error type by downcasting:

struct AnError {
  virtual ~AnError() = default;
  virtual std::string describe() const = 0;
};
struct Specific : public AnError {
  std::string describe() const override {
    return "specific problem has occurred";
  }
};

template <>  // Implement `sus::error::Error` for AnError.
struct sus::error::ErrorImpl<AnError> {
  static std::string display(const AnError& e) { return e.describe(); }
};

sus::Result<void, sus::Box<AnError>> always_error() {
  // Deduces to Result<Box<AnError>>
  return sus::err(sus::into(Specific()));
};

int main() {
  always_error().unwrap();
  // Prints:
  // PANIC! at 'specific problem has occurred',
  // path/to/sus/result/result.h:790:11
}