4
votes

I'm writing a new crate, and I want it to be useable with any implementation of a trait (defined in another crate). The trait looks something like this:

pub trait Trait {
   type Error;
   ...
}

I have my own Error type, but sometimes I just want to forward the underlying error unmodified. My instinct is to define a type like this:

pub enum Error<T: Trait> {
    TraitError(T::Error),
    ...
}

This is similar to the pattern encouraged by thiserror, and appears to be idiomatic. It works fine, but I also want to use ? in my implementation, so I need to implement From:

impl<T: Trait> From<T::Error> for Error<T> {
    fn from(e: T::Error) -> Self { Self::TraitError(e) }
}

That fails, because it conflicts with impl<T> core::convert::From<T> for T. I think I understand why — some other implementor of Trait could set type Error = my_crate::Error such that both impls would apply — but how else can I achieve similar semantics?

I've looked at a few other crates, and they seem to handle this by making their Error (or equivalent) generic over the error type itself, rather than the trait implementation. That works, of course, but:

  • until we have inherent associated types, it's much more verbose. My T actually implements multiple traits, each with their own Error types, so I'd now have to return types like Result<..., Error<<T as TraitA>::Error, <T as TraitB>::Error>> etc;
  • it's arguably less expressive (because the relationship to Trait is lost).

Is making my Error generic over the individual types the best (most idiomatic) option today?

1
Instead of implementing From, you could use .map_err(Error::TraitError)? instead of simply ?, this way the error type is already Error instead of T::Error and ? will use the blanket impl<T> From<T> for T with T = Error.Filipe Rodrigues
Yes, that works, and thinking about it is the only way it can work. The From trait just operates on a type, it doesn't know where it came from. What happens if TraitA::Error and TraitB::Error have the same type? I still have to make Error generic, but it can be generic over just one type, not every individual trait.jbramley
Couldn't you bind T with T: TraitA + TraitB and have two variants in Error, one TraitA(<T as TraitA>::Error) and another as TraitB(<T as TraitB>::Error) and .map_err(...)? depending on which trait is used? Even if they had the same type, they'd be in distinct variants, and you don't need to implement From for Error if using .map_err(...)?.Filipe Rodrigues
Yes, that's exactly what I have, since trying your map_err() suggestion. The only problem I've found so far is that it's still verbose (compared to ?), but the public API is very concise. Now I'm wondering why I haven't seen this used elsewhere.jbramley
The verbosity is required to specify which variant you want to return, I believe this is standard practise when implementing From isn't viable, as it's what map_err was designed for. If this has solved your problem, I'll post an answer summarising the solution.Filipe Rodrigues

1 Answers

2
votes

Instead of implementing From for your Error enum, consider instead using Result::map_err in combination with ? to specify which variant to return. This works even for enums generic over a type using associated types, as such:

trait TraitA {
  type Error;
  fn do_stuff(&self) -> Result<(), Self::Error>;
}

trait TraitB {
  type Error;
  fn do_other_stuff(&self) -> Result<(), Self::Error>;
}

enum Error<T: TraitA + TraitB> {
  DoStuff(<T as TraitA>::Error),
  DoOtherStuff(<T as TraitB>::Error),
}

fn my_function<T: TraitA + TraitB>(t: T) -> Result<(), Error<T>> {
  t.do_stuff().map_err(Error::DoStuff)?;
  t.do_other_stuff().map_err(Error::DoOtherStuff)?;
  Ok(())
}

On the playground

Here the important bits are that Error has no From implementations (aside from blanket ones), and that the variant is specified using map_err. This works as Error::DoStuff can be interpreted as a fn(<T as TraitA>::Error) -> Error when passed to map_err. Same happens with Error::DoOtherStuff.

This approach is scaleable with however many variants Error has and whether or not they are the same type. It might also be clearer to someone reading the function, as they can figure out that a certain error comes from a certain place without needing to check From implementations and where the type being converted from appears in the function.