How to Fix: Converting `anyhow::Error` to `wasmtime::Error` loses downcast information

6 min read

Converting anyhow::Error to wasmtime::Error Without Losing Downcast Information

A subtle error-conversion bug can break one of Rust’s most important debugging guarantees: if converting an anyhow::Error into a wasmtime::Error strips type information, then later downcasting stops working even though the original source error is still conceptually present. That is exactly what this failing test exposes.

The issue appears when an error like TestError(1) is first wrapped into anyhow::Error and then converted into wasmtime::Error. If the conversion path rebuilds the error from formatted text or only preserves a generic source chain, the concrete error type is no longer reachable through downcast_ref or downcast.

For runtimes, embedding APIs, and host-function plumbing, this matters a lot. Consumers often rely on concrete error recovery based on preserved types, not just messages.

Understanding the Root Cause

Rust error interoperability depends on whether the conversion preserves the original dynamic error object. Both anyhow::Error and wasmtime::Error are type-erased containers, but they do not automatically preserve identical internals unless the conversion is implemented carefully.

The bug typically happens in one of these patterns:

  • The conversion creates a new error from to_string() or formatted output.
  • The conversion wraps only the visible source() chain but not the original owned error object.
  • The conversion stores the error behind a different wrapper that no longer supports the expected concrete downcast path.

That means this kind of logic is dangerous:

impl From<anyhow::Error> for wasmtime::Error {
    fn from(err: anyhow::Error) -> wasmtime::Error {
        wasmtime::Error::msg(err.to_string())
    }
}

Why is that wrong? Because err.to_string() keeps only the rendered message. The original TestError type is gone, so this fails conceptually:

let e: anyhow::Error = TestError(1).into();
let e: wasmtime::Error = e.into();
assert!(e.downcast_ref::<TestError>().is_some());

Even if the string matches, the downcast metadata has been discarded.

The correct fix is to preserve the original error object as an owned dyn Error + Send + Sync payload, or to route the conversion through a Wasmtime error constructor that keeps type identity intact.

Step-by-Step Solution

The goal is simple: when converting from anyhow::Error to wasmtime::Error, do not flatten the error into text. Preserve the boxed error so downstream code can still downcast to the original concrete type.

1. Reproduce the failing behavior

#[derive(Debug)]
struct TestError(u32);

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

impl std::error::Error for TestError {}

#[test]
fn anyhow_source_loses_downcast() {
    let e: anyhow::Error = TestError(1).into();
    let e: wasmtime::Error = e.into();

    assert!(e.downcast_ref::<TestError>().is_some());
}

If the assertion fails, the conversion erased the concrete type.

2. Inspect the current conversion implementation

Look for an implementation similar to:

impl From<anyhow::Error> for wasmtime::Error {
    fn from(err: anyhow::Error) -> wasmtime::Error {
        wasmtime::Error::msg(err.to_string())
    }
}

or any variation that reconstructs a brand-new error from a string or from incomplete source-chain data.

3. Convert using the boxed underlying error

The fix is to preserve ownership of the erased error object instead of its text representation. In practice, that means using the boxed form exposed by anyhow::Error and passing it into a wasmtime::Error constructor or internal representation that stores boxed errors.

impl From<anyhow::Error> for wasmtime::Error {
    fn from(err: anyhow::Error) -> wasmtime::Error {
        let boxed: Box<dyn std::error::Error + Send + Sync> = err.into();
        wasmtime::Error::from(boxed)
    }
}

If wasmtime::Error does not currently support that exact constructor, the internal error type should be adjusted so it can retain a boxed trait object directly.

4. If necessary, add an internal variant that stores boxed errors

If Wasmtime currently only has a message-based constructor, introduce a variant that preserves the original boxed error:

enum Repr {
    Message(String),
    Boxed(Box<dyn std::error::Error + Send + Sync>),
}

pub struct Error {
    repr: Repr,
}

impl From<Box<dyn std::error::Error + Send + Sync>> for Error {
    fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
        Self { repr: Repr::Boxed(err) }
    }
}

Then ensure downcast_ref, downcast, and source traversal all operate on that boxed value.

5. Implement downcast support against the preserved payload

impl Error {
    pub fn downcast_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
        match &self.repr {
            Repr::Boxed(err) => err.downcast_ref::<T>(),
            Repr::Message(_) => None,
        }
    }
}

If Wasmtime already has an internal wrapper type, adapt that logic there instead of introducing duplicate storage.

6. Add a regression test

#[test]
#[cfg(feature = "anyhow")]
fn anyhow_source_loses_downcast() -> anyhow::Result<()> {
    let e: anyhow::Error = TestError(1).into();
    let e: wasmtime::Error = e.into();

    let test = e.downcast_ref::<TestError>();
    assert!(test.is_some());
    assert_eq!(test.unwrap().0, 1);
    Ok(())
}

This test should verify not just presence, but the preserved inner value too.

7. Verify no existing message behavior regresses

After changing storage to preserve the boxed error, confirm formatting still works:

#[test]
fn boxed_error_still_formats() {
    let e: anyhow::Error = TestError(7).into();
    let e: wasmtime::Error = e.into();

    assert!(e.to_string().contains("TestError(7)"));
}

A correct fix preserves both human-readable formatting and machine-readable downcasting.

Common Edge Cases

  • Context layers added by anyhow: If the error was wrapped with .context(...) or .with_context(...), make sure the conversion still preserves the underlying error chain in a way that allows useful inspection. Depending on implementation, direct downcast may hit the outer wrapper first.
  • Double boxing: Converting from anyhow::Error into another boxed error and then into wasmtime::Error can still work, but only if every layer preserves std::error::Error object identity rather than rebuilding strings.
  • Message-only constructors: Any use of msg, format!, or plain strings in conversion code will permanently lose the original type.
  • Send + Sync bounds: If the internal Wasmtime error storage requires Send + Sync, ensure the conversion from anyhow::Error produces a compatible boxed error object.
  • Source-chain-only traversal: Some implementations try to recover the original type only through source(). That can be brittle because wrappers may expose intermediate context objects rather than the actual target error for direct downcasting.
  • Feature-gated behavior: Since this issue is under the anyhow feature, verify tests compile and pass both with and without that feature enabled.

FAQ

Why does to_string() break downcasting?

Because to_string() only preserves the rendered message, not the original concrete error type. Once you rebuild a new error from that string, the type identity is gone.

Can the source chain alone preserve downcast support?

Not reliably for this use case. A source chain may preserve causal relationships, but direct downcast_ref on the destination error can still fail if the destination does not own or expose the original boxed error in the expected way.

What is the safest conversion strategy from anyhow::Error to wasmtime::Error?

The safest strategy is to convert anyhow::Error into a boxed dyn std::error::Error + Send + Sync and store that directly inside wasmtime::Error, avoiding all message-only reconstruction paths.

The core takeaway is straightforward: preserve the boxed error object, not just its text. Once Wasmtime stores the original erased payload, downcasting to user-defined error types like TestError works again, and the regression test stops failing for the right reason.

Leave a Reply

Your email address will not be published. Required fields are marked *