Skip to content

Untyped error? #35

@tisonkun

Description

@tisonkun

In @Byron 's PR of vendoring exn (GitoxideLabs/gitoxide#2352), it introduces an Untype structure as the default type param of Exn<E>:

/// A marker to show that type information is not available,
/// while storing all extractable information about the erased type.
/// It's the default type for [Exn].
pub struct Untyped(Box<dyn Error + Send + Sync + 'static>);

impl fmt::Display for Untyped {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        std::fmt::Display::fmt(&self.0, f)
    }
}

impl fmt::Debug for Untyped {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        std::fmt::Debug::fmt(&self.0, f)
    }
}

impl Error for Untyped {}

pub struct Exn<E: std::error::Error + Send + Sync + 'static = Untyped> {
    frame: Box<Frame>,
    phantom: PhantomData<E>,
}

impl<E: Error + Send + Sync + 'static> Exn<E> {
    /// Erase the type of this instance and turn it into a bare `Exn`.
    pub fn erased(self) -> Exn {
        Exn {
            frame: self.frame,
            phantom: Default::default(),
        }
    }
}

Another related extension is that gix-error provides a type-erased Error type that works like anyhow::Error:

/// An error type that wraps an inner type-erased boxed `std::error::Error` or an `Exn` frame.
///
/// In that, it's similar to `anyhow`, but with support for tracking the call site and trees of errors.
///
/// # Warning: `source()` information is stringified and type-erased
///
/// All `source()` values when created with [`Error::from_error()`] are turned into frames,
/// but lose their type information completely.
/// This is because they are only seen as reference and thus can't be stored.
pub struct Error {
    inner: error::Inner,
}

pub(super) enum Inner {
    ExnAsError(Box<exn::Frame>),
    Exn(Box<exn::Frame>),
}

impl Inner {
    fn as_frame(&self) -> &exn::Frame {
        match self {
            Inner::ExnAsError(f) | Inner::Exn(f) => f,
        }
    }
}

impl Error {
    /// Create a new instance representing the given `error`.
    #[track_caller]
    pub fn from_error(error: impl std::error::Error + Send + Sync + 'static) -> Self {
        Error {
            inner: Inner::ExnAsError(Exn::new(error).into()),
        }
    }
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match &self.inner {
            Inner::ExnAsError(err) => std::fmt::Display::fmt(err.as_error(), f),
            Inner::Exn(frame) => std::fmt::Display::fmt(frame, f),
        }
    }
}

impl std::fmt::Debug for Error {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match &self.inner {
            Inner::ExnAsError(err) => std::fmt::Debug::fmt(err.as_error(), f),
            Inner::Exn(frame) => std::fmt::Debug::fmt(frame, f),
        }
    }
}

impl std::error::Error for Error {
    /// Return the first source of an [Exn] error, or the source of a boxed error.
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match &self.inner {
            Inner::ExnAsError(frame) | Inner::Exn(frame) => {
                frame.children().first().map(exn::Frame::as_error_no_send_sync)
            }
        }
    }
}

impl<E> From<Exn<E>> for Error
where
    E: std::error::Error + Send + Sync + 'static,
{
    fn from(err: Exn<E>) -> Self {
        Error {
            inner: Inner::Exn(err.into()),
        }
    }
}

Created this issue to track these ideas. And I'd appreciate it if @Byron could share some design consideration and their typical use cases.

cc @andylokandy, we may consider whether or not include these designs in the exn upstream. So far, I can somehow understand their purpose, but I hesitate about the concrete API and names.

  1. Putting Untyped as the default type param seems a bit too extreme;
  2. The tree-structure and StdError::source's mismatch is still something our users must understand;
  3. Once we have both typed and untyped branches, we need some guidelines and best practices to use them in different scenarios.

Currently, to apply the philosophy of designing for error across different modules as described in our blog, the typed branch is preferable. The untyped branch is mainly for convenience. But I'm not yet convinced that putting such two branches into one crate a good way to go.

cc @80Ltrumpet in case you're interested in this topic and have some spare time to consider the questions here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions