
Handling Rust Errors
- 3 installs
- 1.6k repo stars
- Updated August 5, 2026
- hashintel/hash
Applies HASH error-handling patterns with the error-stack crate for Result and Report types, change_context, and attach.
About
Provides HASH-specific patterns for consistent, debuggable Rust error handling using the error-stack crate, including custom errors, context propagation, and documenting error conditions. A developer uses it when working with Result and Report types in the HASH codebase.
- Patterns for change_context, attach, and ResultExt
- Consistent error handling across the Rust codebase
Handling Rust Errors by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #98 of 121 Rust skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hashintel/hash --skill handling-rust-errorsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 1.6k |
| Last updated | August 5, 2026 |
| Repository | hashintel/hash ↗ |
What it does
Applies HASH error-handling patterns with the error-stack crate for Result and Report types, change_context, and attach.
Files
Rust Error-Stack Patterns
HASH-specific error handling patterns using the error-stack crate for consistent, debuggable error handling across the Rust codebase.
Core Principles
HASH uses `error-stack` exclusively for error handling:
✅ DO:
- Use
Report<MyError>for all error types - Use concrete error types:
Report<MyError> - Import
Errorfromcore::error::(notstd::error::) - Import
ResultExt as _for trait methods
❌ DON'T:
- Use
anyhoworeyrecrates - Use
Box<dyn Error>(except in tests/prototyping) - Use
Report<Box<dyn Error>> - Use
thiserror(usederive_moreinstead)
HashQL Compiler Exception
HashQL compiler code uses a different error handling approach.
Code in libs/@local/hashql/* uses the hashql-diagnostics crate instead of error-stack. This is because compiler errors require rich formatting capabilities:
- Source spans pointing to exact code locations
- Multiple labeled regions within the same diagnostic
- Fix suggestions with replacement text
- Severity levels (error, warning, hint)
Which approach to use:
| Location | Error Handling |
|---|---|
libs/@local/hashql/* (compiler code) | Use hashql-diagnostics → See writing-hashql-diagnostics skill |
| Everywhere else | Use error-stack patterns from this skill |
Traditional error-stack patterns still apply for HashQL infrastructure code (CLI, file I/O, configuration) that doesn't involve compiler diagnostics.
Quick Start Guide
Choose the reference that matches your current task:
Defining Errors
Use when: Creating new error types or error enums
- Define error types with
derive_more - Error enum patterns and variants
- Implement the
Errortrait - Error type hierarchies
Propagating Errors
Use when: Handling Result types, using ? operator
- Convert errors with
.change_context()and.change_context_with() - Add context with
.attach()and.attach_with() - Error conversion patterns
Documenting Errors
Use when: Writing doc comments for fallible functions
# Errorssection format- Link error variants
- Document runtime errors
- Test error conditions
Common Quick Patterns
Creating an Error
use error_stack::Report;
return Err(Report::new(MyError::NotFound))
.attach(format!("ID: {}", id));Propagating with Context
use error_stack::ResultExt as _;
some_result
.change_context(MyError::OperationFailed)
.attach("Additional context")?;Lazy Context (for expensive operations)
use error_stack::ResultExt as _;
expensive_operation()
.change_context(MyError::OperationFailed)
.attach_with(|| format!("Debug info: {:?}", expensive_computation()))?;References
- Defining Errors - Creating new error types or error enums
- Propagating Errors - Handling
Resulttypes, using?operator - Documenting Errors - Writing doc comments for fallible functions
Defining Errors
This guide covers how to define custom error types in HASH using error-stack and derive_more.
---
Basic Error Type
Use derive_more for the Display trait:
use core::error::Error;
#[derive(Debug, derive_more::Display)]
#[display("Operation failed: {_variant}")]
pub enum MyError {
#[display("Resource `{id}` not found")]
NotFound { id: String },
#[display("Operation timed out after {seconds}s")]
Timeout { seconds: u64 },
#[display("Invalid input: {reason}")]
InvalidInput { reason: String },
}
impl Error for MyError {}Key Points:
- Use
#[derive(Debug, derive_more::Display)] - Top-level
#[display("...")]provides fallback message - Per-variant
#[display("...")]for specific messages - Use
{_variant}in top-level to show variant name - Manually implement
Errortrait (justimpl Error for MyError {}) - Import from
core::error::Error, NOTstd::error::Error
---
Error Enum Patterns
Simple Variants
#[derive(Debug, derive_more::Display)]
pub enum DatabaseError {
#[display("Connection failed")]
ConnectionFailed,
#[display("Query timeout")]
Timeout,
#[display("Record not found")]
NotFound,
}
impl Error for DatabaseError {}Variants with Data
#[derive(Debug, derive_more::Display)]
pub enum ValidationError {
#[display("Field `{field}` is required")]
MissingField { field: String },
#[display("Invalid format for `{field}`: expected {expected}")]
InvalidFormat {
field: String,
expected: String,
},
#[display("Value `{value}` out of range [{min}, {max}]")]
OutOfRange {
value: i64,
min: i64,
max: i64,
},
}
impl Error for ValidationError {}Variants with Wrapped Errors
#[derive(Debug, derive_more::Display)]
pub enum ConfigError {
#[display("Failed to read config file")]
ReadFailed,
#[display("Failed to parse config")]
ParseFailed,
#[display("Missing required field: {field}")]
MissingField { field: String },
}
impl Error for ConfigError {}
// Use error-stack to wrap the underlying errors
fn load_config(path: &Path) -> Result<Config, Report<ConfigError>> {
let contents = std::fs::read_to_string(path)
.map_err(|e| Report::new(e))
.change_context(ConfigError::ReadFailed)?;
let config: Config = serde_json::from_str(&contents)
.map_err(|e| Report::new(e))
.change_context(ConfigError::ParseFailed)?;
Ok(config)
}---
Error Type Hierarchies
For complex systems, create error hierarchies:
// High-level service error
#[derive(Debug, derive_more::Display)]
pub enum ServiceError {
#[display("Database operation failed")]
Database,
#[display("Validation failed")]
Validation,
#[display("Authorization denied")]
Authorization,
#[display("External service error")]
External,
}
impl Error for ServiceError {}
// Specific database errors
#[derive(Debug, derive_more::Display)]
pub enum DatabaseError {
#[display("Connection failed")]
ConnectionFailed,
#[display("Query failed")]
QueryFailed,
#[display("Transaction aborted")]
TransactionAborted,
}
impl Error for DatabaseError {}
// Convert specific to general
fn process() -> Result<(), Report<ServiceError>> {
fetch_from_db()
.change_context(ServiceError::Database)?;
validate_input()
.change_context(ServiceError::Validation)?;
Ok(())
}---
Common Patterns
Error with Source Information
#[derive(Debug, derive_more::Display)]
pub enum FileError {
#[display("Failed to open file at `{path}`")]
OpenFailed { path: String },
#[display("Failed to read file at line {line}")]
ReadFailed { line: usize },
#[display("Invalid file format in `{path}`: {reason}")]
InvalidFormat { path: String, reason: String },
}
impl Error for FileError {}Error with Debug Context
#[derive(Debug, derive_more::Display)]
pub enum QueryError {
#[display("Query compilation failed")]
CompilationFailed,
#[display("Query execution failed")]
ExecutionFailed,
#[display("Invalid query parameter: {param}")]
InvalidParameter { param: String },
}
impl Error for QueryError {}
// Usage with context
fn execute_query(sql: &str) -> Result<Rows, Report<QueryError>> {
let compiled = compile(sql)
.change_context(QueryError::CompilationFailed)
.attach_printable(format!("SQL: {}", sql))?;
run(compiled)
.change_context(QueryError::ExecutionFailed)
.attach_printable(format!("Compiled query: {:?}", compiled))?;
// ...
}---
Best Practices
DO:
✅ Use descriptive variant names ✅ Include relevant context in variant fields ✅ Use core::error::Error instead of std::error::Error ✅ Keep error messages user-friendly but informative ✅ Use structured data (fields) instead of formatted strings
DON'T:
❌ Use thiserror (use derive_more instead) ❌ Use Box<dyn Error> in error variants ❌ Include sensitive data in error messages ❌ Make error messages too technical for end users ❌ Create overly generic error types
---
Testing Error Types
#[test]
fn error_display_format() {
let error = MyError::NotFound {
id: "user_123".to_string(),
};
assert_eq!(
error.to_string(),
"Resource `user_123` not found",
"Error message should match expected format"
);
}
#[test]
fn error_with_report() {
let report = Report::new(MyError::Timeout { seconds: 30 })
.attach_printable("During database query");
assert!(matches!(
report.current_context(),
MyError::Timeout { seconds: 30 }
));
}---
Related References
- Propagating Errors - Handle and propagate these errors
- Documenting Errors - Document these in functions
Documenting Errors
This guide covers how to document error conditions in HASH Rust code.
---
Error Documentation Format
All fallible functions must document their errors with an # Errors section.
Basic Format
/// Creates a new web in the system.
///
/// Registers a new web with the given parameters and ensures uniqueness.
///
/// # Errors
///
/// - [`WebAlreadyExists`] if a web with the same ID already exists
/// - [`AuthorizationError`] if the account lacks permission
/// - [`DatabaseError`] if the operation fails at the database level
///
/// [`WebAlreadyExists`]: WebError::WebAlreadyExists
/// [`AuthorizationError`]: WebError::Authorization
/// [`DatabaseError`]: WebError::Database
pub fn create_web(&mut self) -> Result<WebId, Report<WebError>> {
// Implementation
}Key Elements:
# Errorssection header- Bullet point for each error variant
- Intra-doc links using `
[VariantName]` syntax - Link definitions at the bottom
---
Linking Error Variants
Same Module Errors
#[derive(Debug, derive_more::Display)]
pub enum UserError {
#[display("User not found")]
NotFound,
#[display("Unauthorized access")]
Unauthorized,
}
impl Error for UserError {}
/// Fetches a user by ID.
///
/// # Errors
///
/// - [`NotFound`] if the user doesn't exist
/// - [`Unauthorized`] if the caller lacks permission
///
/// [`NotFound`]: UserError::NotFound
/// [`Unauthorized`]: UserError::Unauthorized
pub fn fetch_user(id: &str) -> Result<User, Report<UserError>> {
// Implementation
}Cross-Module Errors
/// Validates user input.
///
/// # Errors
///
/// - [`ValidationError::EmptyInput`] if the input is empty
/// - [`ValidationError::TooLong`] if the input exceeds max length
///
/// [`ValidationError::EmptyInput`]: crate::validation::ValidationError::EmptyInput
/// [`ValidationError::TooLong`]: crate::validation::ValidationError::TooLong
pub fn validate_input(input: &str) -> Result<(), Report<ValidationError>> {
// Implementation
}---
Runtime/Dynamic Errors
For errors created dynamically (not enum variants):
/// Validates that all input values are unique.
///
/// # Errors
///
/// Returns a validation error if the input contains duplicate values
pub fn validate_unique(values: &[String]) -> Result<(), Report<ValidationError>> {
for (i, value) in values.iter().enumerate() {
if values[i + 1..].contains(value) {
return Err(Report::new(ValidationError::DuplicateValue))
.attach(format!("Duplicate: {}", value));
}
}
Ok(())
}Note: No intra-doc links needed for dynamically created errors - just describe the condition.
---
Multiple Error Sources
When a function can fail for many reasons:
/// Processes a configuration file.
///
/// Reads the file from disk, parses it, and validates the contents.
///
/// # Errors
///
/// - [`ReadFailed`] if the file cannot be read
/// - [`ParseFailed`] if the file contains invalid syntax
/// - [`ValidationFailed`] if the configuration is semantically invalid
/// - Returns an error if any required field is missing
///
/// [`ReadFailed`]: ConfigError::ReadFailed
/// [`ParseFailed`]: ConfigError::ParseFailed
/// [`ValidationFailed`]: ConfigError::ValidationFailed
pub fn process_config(path: &Path) -> Result<Config, Report<ConfigError>> {
// Implementation
}---
Async Function Errors
Document the same way as sync functions:
/// Fetches user data from the database.
///
/// # Errors
///
/// - [`ConnectionFailed`] if the database connection is unavailable
/// - [`QueryFailed`] if the SQL query fails
/// - [`NotFound`] if no user with the given ID exists
///
/// [`ConnectionFailed`]: DatabaseError::ConnectionFailed
/// [`QueryFailed`]: DatabaseError::QueryFailed
/// [`NotFound`]: DatabaseError::NotFound
pub async fn fetch_user_async(id: i64) -> Result<User, Report<DatabaseError>> {
// Implementation
}---
Testing Error Conditions
Write tests for each documented error case:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_not_found_returns_error() {
let result = fetch_user("nonexistent_id");
let err = result.expect_err("should return error for nonexistent user");
// Check the error type
assert!(
matches!(
err.current_context(),
UserError::NotFound
),
"should return NotFound error"
);
}
#[test]
fn unauthorized_access_returns_error() {
let result = fetch_user_without_permission("user_123");
let err = result.expect_err("should return error for unauthorized access");
assert!(
matches!(
err.current_context(),
UserError::Unauthorized
),
"should return Unauthorized error"
);
}
#[test]
fn successful_fetch() {
let result = fetch_user("valid_user_id");
result.expect("should successfully fetch existing user");
}
}Key Points:
- Test every error variant mentioned in docs
- Use
.expect_err("should...")format - Assert on specific error types with
matches! - Include success case tests too
---
Examples in Documentation
When writing # Examples sections for fallible functions:
Prefer ? Operator
Use ? for error propagation in examples whenever possible:
/// Fetches and processes user data.
///
/// # Examples
///
/// ```
/// # use myapp::{fetch_user, UserError};
/// # use error_stack::Report;
/// let user = fetch_user("user_123")?;
/// println!("User: {}", user.name);
/// # Ok::<_, Box<dyn core::error::Error>>(())
/// ```
pub fn fetch_user(id: &str) -> Result<User, Report<UserError>> {
// Implementation
}Key Points:
- Use
?instead of.unwrap()ormatch - Add
# Ok::<_, Box<dyn core::error::Error>>(())at the end - This makes examples more realistic and idiomatic
When NOT to Use ?
Only use explicit error handling when demonstrating error handling itself:
/// Validates user input.
///
/// # Examples
///
/// ```
/// # use myapp::{validate_input, ValidationError};
/// match validate_input("test") {
/// Ok(()) => println!("Valid"),
/// Err(e) => eprintln!("Invalid: {}", e),
/// }
/// ```
pub fn validate_input(input: &str) -> Result<(), Report<ValidationError>> {
// Implementation
}---
Best Practices
DO:
✅ Document ALL error cases in fallible functions ✅ Use intra-doc links for error variants ✅ Be specific about error conditions ✅ Test each documented error case ✅ Update docs when adding new error variants ✅ Link to error enum documentation when relevant
DON'T:
❌ Skip error documentation ("obvious" cases still need docs) ❌ Use plain text without intra-doc links ❌ Document only some error variants ❌ Write vague error descriptions ("may fail") ❌ Forget to update tests when docs change
---
Examples
Complete Function Documentation
#[derive(Debug, derive_more::Display)]
pub enum RegistrationError {
#[display("Email already registered")]
EmailTaken,
#[display("Invalid email format")]
InvalidEmail,
#[display("Password too weak")]
WeakPassword,
}
impl Error for RegistrationError {}
/// Registers a new user in the system.
///
/// Creates a new user account with the provided email and password.
/// The email must be unique and the password must meet security requirements.
///
/// # Errors
///
/// - [`EmailTaken`] if another user is already registered with this email
/// - [`InvalidEmail`] if the email format is invalid
/// - [`WeakPassword`] if the password doesn't meet security requirements
///
/// [`EmailTaken`]: RegistrationError::EmailTaken
/// [`InvalidEmail`]: RegistrationError::InvalidEmail
/// [`WeakPassword`]: RegistrationError::WeakPassword
///
/// # Examples
///
/// ```
/// # use myapp::{register_user, RegistrationError};
/// # use error_stack::Report;
/// let user_id = register_user("user@example.com", "SecurePass123!")?;
/// # Ok::<_, Box<dyn core::error::Error>>(())
/// ```
pub fn register_user(email: &str, password: &str) -> Result<UserId, Report<RegistrationError>> {
// Implementation
}---
Related References
- Defining Errors - Create error types
- Propagating Errors - Add context and convert errors
Propagating Errors
This guide covers how to propagate errors through your code using error-stack.
---
Basic Error Propagation
Using the ? Operator
use error_stack::{Report, ResultExt as _};
fn process_data(id: &str) -> Result<Data, Report<MyError>> {
// Direct propagation - error types match
let raw = fetch_raw_data(id)?;
// Convert and propagate
let processed = transform_data(raw)
.change_context(MyError::TransformFailed)?;
Ok(processed)
}Note: Import ResultExt as _ to bring trait methods into scope without polluting namespace.
---
Converting Error Types
Using .change_context()
Convert one error type to another:
use error_stack::ResultExt as _;
fn load_user(id: &str) -> Result<User, Report<UserError>> {
// Convert DatabaseError → UserError
let data = db::fetch(id)
.change_context(UserError::DatabaseFailed)?;
// Convert ParseError → UserError
let user = parse_user_data(data)
.change_context(UserError::ParseFailed)?;
Ok(user)
}---
Adding Context
Using .attach()
Add debugging information without changing error type:
use error_stack::ResultExt as _;
fn process_batch(items: &[Item]) -> Result<(), Report<ProcessError>> {
for (idx, item) in items.iter().enumerate() {
process_item(item)
.attach(format!("Failed at index {}", idx))
.attach(format!("Item ID: {}", item.id))?;
}
Ok(())
}Combining Context and Conversion
use error_stack::ResultExt as _;
fn update_user(id: &str, data: UserData) -> Result<User, Report<UserError>> {
let existing = fetch_user(id)
.change_context(UserError::FetchFailed)
.attach(format!("User ID: {}", id))?;
let updated = apply_updates(existing, data)
.change_context(UserError::UpdateFailed)
.attach(format!("Updates: {:?}", data))?;
save_user(&updated)
.change_context(UserError::SaveFailed)
.attach(format!("User: {:?}", updated.id))?;
Ok(updated)
}---
Lazy Context Attachment
For expensive computations, use _with variants to defer evaluation:
Using .attach_with()
use error_stack::ResultExt as _;
fn process_large_data(data: &LargeData) -> Result<(), Report<ProcessError>> {
expensive_operation(data)
.change_context(ProcessError::OperationFailed)
// Only compute debug string if error occurs
.attach_with(|| format!("Data summary: {:?}", data.compute_summary()))?;
Ok(())
}Using .change_context_with()
When error creation itself is expensive:
use error_stack::ResultExt as _;
fn process_with_expensive_error(item: &Item) -> Result<(), Report<ComplexError>> {
operation(item)
// Error variant creation might involve computation
.change_context_with(|| ComplexError::from_item_analysis(item))
.attach_with(|| format!("Item state: {:?}", item.expensive_debug()))?;
Ok(())
}Rule of thumb: Use _with variants only when the closure does non-trivial work.
---
Async Error Propagation
Error propagation works the same in async code:
use error_stack::ResultExt as _;
async fn fetch_and_process(id: String) -> Result<Data, Report<ProcessError>> {
// Propagate async errors
let raw = fetch_async(&id)
.await
.change_context(ProcessError::FetchFailed)
.attach(format!("ID: {}", id))?;
// Mix sync and async operations
let validated = validate_data(&raw)
.change_context(ProcessError::ValidationFailed)?;
let processed = process_async(validated)
.await
.change_context(ProcessError::ProcessingFailed)?;
Ok(processed)
}Important: The .change_context() call can appear before .await because ResultExt is in scope:
use error_stack::{FutureExt as _, ResultExt as _};
// ✅ This works - `FutureExt` trait is in scope
let result = async_operation()
.change_context(MyError::Failed)
.await?;
// ✅ Also correct - context added after await using `ResultExt`
let result = async_operation()
.await
.change_context(MyError::Failed)?;---
Converting External Errors
Standard Library Errors
use error_stack::{Report, ResultExt as _};
fn read_file(path: &Path) -> Result<String, Report<FileError>> {
// Convert std::io::Error
let contents = std::fs::read_to_string(path)
.map_err(Report::new)
.change_context(FileError::ReadFailed)
.attach(format!("Path: {}", path.display()))?;
Ok(contents)
}Third-Party Library Errors
use error_stack::{Report, ResultExt as _};
fn parse_json(json: &str) -> Result<Value, Report<ParseError>> {
// Convert serde_json::Error
let value: Value = serde_json::from_str(json)
.map_err(Report::new)
.change_context(ParseError::JsonParseFailed)
.attach(format!("JSON length: {}", json.len()))?;
Ok(value)
}---
Error Chains
Build error chains for complex operations:
use error_stack::ResultExt as _;
fn complex_operation(id: &str) -> Result<Output, Report<ServiceError>> {
// Each step adds to the error chain
let data = fetch_data(id)
.change_context(ServiceError::FetchFailed)
.attach(format!("Step 1: fetch data for {}", id))?;
let validated = validate(data)
.change_context(ServiceError::ValidationFailed)
.attach("Step 2: validation")?;
let transformed = transform(validated)
.change_context(ServiceError::TransformFailed)
.attach("Step 3: transformation")?;
let result = save(transformed)
.change_context(ServiceError::SaveFailed)
.attach("Step 4: save result")?;
Ok(result)
}---
Best Practices
DO:
✅ Always add context when propagating errors ✅ Use .change_context() to convert error types at boundaries ✅ Include relevant IDs, indices, or state in attachments ✅ Use _with variants for non-trivial closures ✅ Import ResultExt as _ to avoid namespace pollution ✅ Add context close to where the error occurs
DON'T:
❌ Propagate errors without context ❌ Add too much context (avoid duplicates) ❌ Include sensitive data in attachments ❌ Use unwrap() or expect() in production code ❌ Silently ignore errors with let _ = ... ❌ Use _with variants for trivial operations
---
Common Patterns
Option to Result Conversion
use error_stack::Report;
fn get_user_by_id(id: &str, users: &HashMap<String, User>) -> Result<&User, Report<UserError>> {
users
.get(id)
.ok_or_else(|| Report::new(UserError::NotFound))
.attach(format!("User ID: {}", id))
}Multiple Error Sources
use error_stack::ResultExt as _;
fn process_config(path: &Path) -> Result<Config, Report<ConfigError>> {
let raw = std::fs::read_to_string(path)
.map_err(Report::new)
.change_context(ConfigError::ReadFailed)?;
let parsed: RawConfig = toml::from_str(&raw)
.map_err(Report::new)
.change_context(ConfigError::ParseFailed)?;
validate_config(&parsed)
.change_context(ConfigError::ValidationFailed)?;
Ok(build_config(parsed))
}---
Related References
- Defining Errors - Create error types
- Documenting Errors - Document error conditions