Implementing serde::{Serialize, Deserialize} for std::io::Error

I was hoping someone might be able to suggest a good case study of someone having elegantly managed to wrap std::io::Error inside of an Error struct that implements serde::{Serialize, Deserialize} traits? It's popped up in threads a couple times (such as here and here in the serde issue tracker on Github), but I haven't been able to find the actual in-code examples of folks having done so successfully. Any suggestions would be greatly appreciated. Thanks!

You can't implement foreign traits (Serialize, Deserialize) for a foreign type (std::io:Error) due to Rust's orphan rule. What you can do instead is create a newtype and implement the traits for it.

In one of the links that you shared there's an example for this.

std::io::Error does expose quite a few of its structural details; admitted ErrorKind being non-exhaustive is a slight limitation, and the ability to wrap any Box<dyn Error + Send + Sync> cause means you’ll have to lose some details there… what information do you require here (w.r.t. Display, Debug and .source() info) – perhaps for certain concrete types, you’re even interested in preserving the actual value, as obtained via downcast? So there’s some customization to be had.

Also, in cases where it wraps a RawOsError, you can inspect and represent that if you like, but it would not preserve meaning across platforms, so there’s a design decision to be made, perhaps specific to the intended use-case.

1 Like

Yes, on the same page about the orphan rule! I believe this tends to be taken into account by most error types via

enum Error {
    Io(std::io::Error),
    Other
}

which sort of natively implements the NewType pattern as a wrapper anyway, correct? Are you referring to the first link #2268, where someone says the following?


impl Serialize for PrintError {
  fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
      where
          S: serde::Serializer {
      serializer.serialize_str(self.to_string().as_ref())
  }
}

I can see that in this case Serialize creates a String error message, but misses stuff like ErrorKind, in addition of course to a potential reference implementation of Deserialize as well.

Definitely agree on some of the challenges there! I think I was largely hoping propagate the ErrorKind and a String of the error message, if it exists. I'm using thiserror to wrap the error, so I think that I should be able to take care of things like Display via the #[derive(Error)] macro which helps with that.

I guess it just feels to me like I can't be the first person to be trying to implement serde for something with io::Error, and I'm sure that some other libraries must have tried to implement something similar at some point. To your point, due to the variance, it's unlikely that there's a single idiomatic pattern, but was hoping to find maybe a couple of examples that I could base an implementation off of :slight_smile:

Edit: I also don't really mind tossing in an _ => Other statement as well to deal with non-exhaustive; this is largely best-effort, and I have a number of other error modes that I'm trying to manage, and would love to get something mildly elegant outside of just skipping that field altogether in my larger Error enum.

As a starting point, it looks like you can think of io::Error internals as essentially an enum:

// the type *synonym* is unstable; the numerical value of the error code,
// as an `i32`, is stably accessible though

#[unstable(feature = "raw_os_error_ty", issue = "107792")]
pub type RawOsError = i32;


enum Error {
    Os(RawOsError),
    Simple(io::ErrorKind),
    SimpleMessage(&'static (io::ErrorKind, &'static str)),
    Custom(Box<(io::ErrorKind, Box<dyn std::error::Error + Send + Sync>)>),
}

Of these cases, Os, Simple and Custom are all user-constructible, via io::Error::from_raw_os_error, <io::Error as From<io::ErrorKind>>::from and io::Error::new, respectively.

And you can detect the Os case with io::Error::raw_os_error getting the RawOsError, detect the Custom case with io::Error::get_ref, accessing the dyn Error + Send + Sync, while the ErrorKind is accessible with io::Error::kind in this case.

Serializing the dyn Error, and deserializing the result into something sensible is its own design challenge, as noted above, though maybe serde_error - Rust could be a reasonable approach.

Constructing SimpleMessage appears impossible (relevant to Deserialize), but a Custom with a Box<String> seems like an appropriate approximation to me. If you care about deserialize>>serialize to re-produce the thing you’ve started with, then you could consider adding String as a special-case (handled by trying a downcast) for dyn Error serialization, and give a String-containing Custom io::Error the same serialized representation as SimpleMessage.

SimpleMessage and Simple are a bit tricky to differentiate (without relying on technically-unstable Debug output). The most practical approach is probably to compare the Display-output of the io::Error with the Display-output of the contained io::ErrorKind, and just assume Simple whenever they’re equal. (Though caveats about ErrorKind’s unstable variants apply; see below in the last paragraph. This means, if you’re going to convert an unstable variant to ErrorKind::Other, then preserving its original Display output might be desirable even when the original error was of the Simple kind.)

Display is also the way to extract the contained message (e.g. as a String) for the SimpleMessage case; and io::Error::kind – once again – provides the io::ErrorKind for the SimpleMessage case or the Simple case.

io::ErrorKind can be worked with by pattern-matching; e.g. translating between it and an identically-defined enum of your own which derives Serialize, Deserialize; while non-exhaustiveness can be handled by just mapping possible future extensions (as well as existing unstable kinds, and the hidden unstable Uncategorized case) to Other.

1 Like

Yes. How much information you want in the error's json representation will depend on how far down the rabbit hole are you willing to go.

This is really comprehensive, and I appreciate the time you put into this response :slight_smile: In all candor, it sounds like I probably have some experimenting to do! I'll probably start with building a roughly-equivalent enum that I can derive those traits on, then start trying to work my way down a pattern-matching tree for From/Into for bits and pieces to enable handling the relevant cases. If I can come up with a pattern that I think works well in the near-ish future, I'll tag an update onto this thread. Thank you again!

If you're creating your own Error type, then instead of storing io::Error as-is, I suggest writing your own impl From<io::Error> for Error and extracting all the information you want from it right there.
The io::Error can be arbitrarily complex due to holding Box<dyn Error> and potentially a whole chain of source() errors of various types, so if there's any information in there that you need, get it and keep it while you have the original object available, instead of trying to make it survive serialization and deserialization.

4 Likes

Thank you for the suggestion! Just to clarify, this would also then mean that each time I had a section of code in which I could come across an std::io::Error, you're suggesting it would look something like the following

fn do_some_io() -> Result<(), MyError> {
    potentially_creates_io_error().map_err(|e| Into::<MyError>::into(e))?
    Ok(())
}

#[derive(Serialize, Deserialize)]
enum MyError {
    // This
    Io(String),
    // Not this, which would toss an error for those traits
    BadIoPattern(#[from] std::io::Error),
    Other
}

impl Into<MyError> for std::io::Error {
    fn into(value: std::io::Error) -> MyError {
        // Extract the information from io::Error
        // For example, `match`-ing on ErrorKind and turning that into a string
        let msg = get_string_from_errorkind(&value);
        MyError::Io(msg)
    }
}

Normally you implement From not Into:
https://doc.rust-lang.org/std/convert/trait.From.html

One should always prefer implementing From over Into because implementing From automatically provides one with an implementation of Into thanks to the blanket implementation in the standard library.

You don't need to call the into method since the ? operator calls from for you.

        fn do_some_io() -> Result<(), MyError> {
            potentially_creates_io_error()?; // <<<< calls `from`
            Ok(())
        }

        impl From<std::io::Error> for MyError {
            fn from(value: std::io::Error) -> MyError {
                // Extract the information from io::Error
                // For example, `match`-ing on ErrorKind and turning that into a string
                let msg = get_string_from_errorkind(&value);
                MyError::Io(msg)
            }
        }

? calls From, so you don't need to change any syntax at the call site.

#[from] in thiserror is just boilerplate for the most basic impl From code, nothing magic. You can write your own.

So the question is, what information we want from io::Error.

I used to capture all kinds of errors and convert them into my crate errors via ? by wrapping them. Until I wanted my crate error to be Clone, but the problem is that not all of the wrapped errors are Clone.

Then I thought, what do I need from an error anyway? Usually, just the message, and also all messages from sources. So instead of wrapping, I now rather convert all errors to my crate error by taking the message, and the messages recursively for all sources, and possible add a prefix to the message indicating the error type.

Also, for io::Error, if it comes from a file access, as it most often does, I wrap it in a crate error that captures the file name.

So every error becomes a nesting of only crate errors. I have not yet looked into serializing and deserializing errors, but basically, they are just a list of Strings.