Trait design with different possible error type for each function

Hi everyone,

I am seeking some advice on a trait I am designing.

So I work with embedded systems, and some of our services need to identify the device they are running on, specifically its serial number, the OS image/version, etc. For that I have the following trait, that users can implement depending on their device:

trait Identify {
  type Error;

  fn get_os_name() -> Result<String, Self::Error>;
  fn get_os_version() -> Result<String, Self::Error>;
  // and so on
}

One example of use cases is with an OS upgrade version checker:

fn is_upgrade_allowed(id: &impl Identify) -> bool {
  let version = match id.get_os_version() {
	Ok(v) => v,
	Err(e) => // print or log error somewhere and return false, or return an other error (with a Result<bool, ...> return type)
  }

  // do some stuff with version and return true if it verifies some conditions
}

The issue I have with my design is that it forces the user to use the same error type for all these functions. This might actually be good for consistency, but another problem is that some users could implement such functions in an Infallible way, which this trait can't describe (other than setting Error to Infallible but that only works if all functions are infallible).

I have thought of having a trait for each function, and then a super-trait which regroups them all, but I find that too much decoupling and it doesn't really show that all these functions are part of the same feature set.

There is also the solution of having an associated type per function, but that seems really ugly.

So I want your feedback, is there any way to solve this issue? Is my trait design bad? Or is it acceptable since anyway when using something that implements Identify we have no clue on the actual implementation and thus we need to handle possible errors?

Thanks

That's the way.

I don't know, this seems cumbersome and not very elegant. Are there any well-known Rust traits that had to deal with this issue? Any in the standard library or in popular crates?

This trait is already very much not designed to accommodate infallible functions. Anyone willing to bound their generics by this trait is going to have to handle errors anyway. At this point, I don't see value in trying to implement the functions infallibly at the type level; just unconditionally returning Ok(...) seems to fit the bill.

Why get_os_name() and get_os_version() would want to return different types of errors is beyond my understanding; I would very much consider that bad practice. If the fact that getting the name or the version failed is an important distinction, it should probably be described by two variants in an enum Error.

2 Likes

Indeed, that's what I was trying to say whan I said that people using something that implements Identify would be forced to handle errors anyway because they don't know the actual implementation. So, as you say, there is no benefit in trying to implement these functions infallibly.

Well you're right, I found that it was good for consistency to force users to use the same error type for their errors. I do exactly what you say for my own implementations of Identify, I describe all my possible errors in a custom Error enum. I just wondered if that was a good design choice (to force users to do so).

I'm actually not sure what a less ugly solution could look like. You'd need some special syntax for anonymous unique associated types which are filled out via inference. That doesn't feel impossible, mind you (though I wouldn't trust my gut feeling too much), though it surely would result in wild type-errors in strange situations. Part of the benefit of explicit types are that they make refactoring your program less error prone by following type errors.


One thing to consider is something like this:

trait Identify {
    type OsNameError;
    type OsVersionError;
    // and so on
  
    fn get_os_name(&self) -> Result<String, Self::OsNameError>;
    fn get_os_version(&self) -> Result<String, Self::OsVersionError>;
    // and so on
}

trait IdentifyP {
    type Error;
    
    fn get_os_name(&self) -> Result<String, Self::Error>;
    fn get_os_version(&self) -> Result<String, Self::Error>;
    // and so on
}

impl<T:IdentifyP> Identify for T {
    type OsNameError = T::Error;
    type OsVersionError = T::Error;
    // and so on

    fn get_os_name(&self) -> Result<String, Self::OsNameError> {
        self.get_os_name()
    }

    fn get_os_version(&self) -> Result<String, Self::OsVersionError> {
        self.get_os_version()
    }
    // and so on
}

In some way you can then have both worlds. You write all your code for Identify, but any struct can implement IdentifyP if they want a unified error type and then Rust will generate an implementation for Identify on their behalf.

That's a bit less ugly? No idea!


Of course, if a user has 100 different Identify structs that have the same 3 errors interspersed across the various functions of Identify, I would simply expect the user to create their own trait to blanket impl your library's trait.

If I see a trait with 10 generics that I'm aiming to use a bit, I immediately think whether an blanket impl will simplify things for me. I imagine that's a pretty common Rust pattern. In that sense, IdentifyP doesn't need to be part of your library as you might expect a user to create a custom version of that idea for their own needs.

Thank you very much for all your answers, it was very instructive.

@Trequetrum great proposition! It could do the job. But finally I opted for somthing simpler for the moment, where I only allow one type of error for the trait. As discussed with @H2CO3, it makes more sense and it is more consistent to have the same error type returned for all methods. And, apart from all of that, these methods are used to define behaviour for a D-Bus service so I actually removed the Error associated type and made my methods return a D-Bus error type (provided by the zbus crate).

Thank you all and have a great day!

1 Like