Error Handling: Shell to Rust
Bash error handling relies on exit codes, set -e, and trap. Bashrs converts
these patterns into Rust’s Result type, providing typed errors with context
at every failure point.
set -e to Result Propagation
Bash
set -e
mkdir -p /tmp/build
cp -r src/ /tmp/build/
cargo build --release
With set -e, any command that returns a non-zero exit code terminates the
script. The equivalent in Rust is the ? operator on Result:
Rust
#![allow(unused)]
fn main() {
fn build() -> Result<()> {
fs::create_dir_all("/tmp/build")?;
copy_dir("src/", "/tmp/build/")?;
let status = Command::new("cargo")
.args(["build", "--release"])
.status()
.context("Failed to start cargo build")?;
if !status.success() {
anyhow::bail!("cargo build exited with {status}");
}
Ok(())
}
}
Unlike set -e, each ? propagation carries context about which operation
failed. Bash’s set -e provides no indication of which command failed when
the script exits silently.
Exit Codes to Typed Errors
Bash
validate_config() {
if [ ! -f "$CONFIG_FILE" ]; then
echo "Config file not found" >&2
return 1
fi
if ! jq empty "$CONFIG_FILE" 2>/dev/null; then
echo "Invalid JSON in config" >&2
return 2
fi
return 0
}
Rust
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
enum ConfigError {
#[error("Config file not found: {path}")]
NotFound { path: PathBuf },
#[error("Invalid JSON in config: {source}")]
InvalidJson {
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
fn validate_config(path: &Path) -> Result<Config, ConfigError> {
let content = fs::read_to_string(path)
.map_err(|_| ConfigError::NotFound { path: path.into() })?;
let config: Config = serde_json::from_str(&content)
.map_err(|e| ConfigError::InvalidJson {
path: path.into(),
source: e,
})?;
Ok(config)
}
}
Numeric exit codes (1, 2) become named enum variants with structured data. Callers can match on the error type and take specific recovery actions rather than checking magic numbers.
Trap Handlers to Drop
Bash
TMPDIR=$(mktemp -d)
trap "rm -rf ${TMPDIR}" EXIT
# Work with temporary files...
cp important.dat "${TMPDIR}/work.dat"
process "${TMPDIR}/work.dat"
Rust
#![allow(unused)]
fn main() {
use tempfile::TempDir;
fn process_with_temp() -> Result<()> {
let tmpdir = TempDir::new()?;
// tmpdir is automatically deleted when it goes out of scope
let work_path = tmpdir.path().join("work.dat");
fs::copy("important.dat", &work_path)?;
process(&work_path)?;
Ok(())
// TempDir::drop() removes the directory here
}
}
Bash trap ... EXIT is a cleanup hook that runs when the script exits.
Rust’s Drop trait serves the same purpose but is scoped to the owning
variable. The tempfile crate provides TempDir which deletes itself on
drop, even if the function returns early due to an error.
Pipefail to Checked Pipelines
Bash
set -o pipefail
curl -s "$URL" | jq '.data' | process_data
Without pipefail, only the exit code of the last command in a pipeline is
checked. With it, any failure in the chain is caught. In Rust, each step is
checked individually:
Rust
#![allow(unused)]
fn main() {
fn fetch_and_process(url: &str) -> Result<()> {
let response = Command::new("curl")
.args(["-s", url])
.output()
.context("curl failed")?;
if !response.status.success() {
anyhow::bail!("curl returned {}", response.status);
}
let parsed: Value = serde_json::from_slice(&response.stdout)
.context("Failed to parse JSON response")?;
let data = parsed.get("data")
.context("Missing 'data' field in response")?;
process_data(data)?;
Ok(())
}
}
Key Takeaways
set -emaps toResultwith?propagation, but each step includes context about what failed.- Numeric exit codes become typed error enums with structured diagnostic data.
trap ... EXITcleanup maps to Rust’sDroptrait, which runs even on early returns.set -o pipefailbecomes explicit status checks on each pipeline stage.- Rust errors compose: a function can wrap lower-level errors with
.context()to build a full failure trace.
Navigate: Table of Contents