Error types in Sail

Sail's most repeated pattern is also its most teachable: every crate defines its own error enum and Result alias, and propagates them via ? and #[from]. Combined with the workspace-wide unwrap_used = "deny", this is what keeps a 36-crate Rust project honest.

The canonical shape

Every crate's error.rs looks like this:

crates/sail-execution/src/error.rs
use std::sync::PoisonError;
 
use datafusion::common::DataFusionError;
use prost::{DecodeError, EncodeError, UnknownEnumValue};
use thiserror::Error;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinError;
 
pub type ExecutionResult<T> = Result<T, ExecutionError>;
 
#[derive(Debug, Error)]
pub enum ExecutionError {
    #[error("error in DataFusion: {0}")]
    DataFusionError(#[from] DataFusionError),
    #[error("invalid argument: {0}")]
    InvalidArgument(String),
    #[error("error in JSON serde: {0}")]
    JsonError(#[from] serde_json::Error),
    #[error("IO error: {0}")]
    IoError(#[from] std::io::Error),
    #[error("error in Tonic transport: {0}")]
    TonicTransportError(#[from] tonic::transport::Error),
    #[error("error in Tonic status: {0}")]
    TonicStatusError(#[from] tonic::Status),
    #[error("internal error: {0}")]
    InternalError(String),
}
 
impl From<JoinError> for ExecutionError {
    fn from(error: JoinError) -> Self {
        ExecutionError::InternalError(error.to_string())
    }
}
 
impl<T> From<PoisonError<T>> for ExecutionError {
    fn from(error: PoisonError<T>) -> Self {
        ExecutionError::InternalError(error.to_string())
    }
}
 
impl<T> From<mpsc::error::SendError<T>> for ExecutionError {
    fn from(error: mpsc::error::SendError<T>) -> Self {
        ExecutionError::InternalError(error.to_string())
    }
}

Three things to notice:

  1. pub type ExecutionResult<T> = Result<T, ExecutionError>; — every caller writes ExecutionResult<T> and never has to repeat the error type.
  2. #[from] on each variant that can be derived from a foreign error. Now let x = some_io_op()?; automatically converts std::io::Error to ExecutionError::IoError. No manual map_err at the call site.
  3. Manual From impls for generic types like PoisonError<T>, mpsc::SendError<T>, JoinError. #[from] cannot infer the generic parameter, so you write the impl by hand. All three squash into InternalError(String) — they are infrastructure errors with no useful structured payload.

Constructor helpers cut boilerplate

unwrap_used = "deny" means every error path is real code, not .unwrap(). To keep call sites readable, Sail's sail-common adds constructor helpers:

crates/sail-common/src/error.rs
use thiserror::Error;
 
pub type CommonResult<T> = Result<T, CommonError>;
 
#[derive(Debug, Error)]
pub enum CommonError {
    #[error("missing argument: {0}")]
    MissingArgument(String),
    #[error("invalid argument: {0}")]
    InvalidArgument(String),
    #[error("not supported: {0}")]
    NotSupported(String),
    #[error("internal error: {0}")]
    InternalError(String),
}
 
impl CommonError {
    pub fn missing(message: impl Into<String>) -> Self {
        CommonError::MissingArgument(message.into())
    }
 
    pub fn invalid(message: impl Into<String>) -> Self {
        CommonError::InvalidArgument(message.into())
    }
 
    pub fn unsupported(message: impl Into<String>) -> Self {
        CommonError::NotSupported(message.into())
    }
 
    pub fn internal(message: impl Into<String>) -> Self {
        CommonError::InternalError(message.into())
    }
}

Call sites become CommonError::invalid("port out of range") instead of CommonError::InvalidArgument("port out of range".to_string()). impl Into<String> accepts &str, String, and Cow<str>, so callers do not write .to_string() either.

This pattern is the secret to making unwrap_used = "deny" liveable across hundreds of files. The error types are easy to throw.

Three lessons for your own code

And the workspace lint that ties it together

Repeated from the architecture map because it bears repeating:

Cargo.toml (workspace)
[workspace.lints.clippy]
unwrap_used = "deny"
expect_used = "deny"
panic       = "deny"

In tests, Sail enables .unwrap() locally with #[expect(clippy::unwrap_used)] at the module top. #[expect] differs from #[allow]: if the lint stops firing (because you removed the .unwrap()), compilation fails, forcing you to remove the #[expect] annotation too. No dead suppressions.

This is the discipline that lets Sail say with confidence: "We don't panic." If your project wants to make the same claim, copy this exact lint policy.