One error type for every layer
Every framework has its own error type. Your application needs exactly one.
Earlier drafts of these snippets would have panicked on the first problem: no GPU, a missing font, a window the operating system refused to create. Production code treats those as ordinary outcomes. Every snippet in the book now avoids unwrap() and expect(). The difficulty is that every framework reports failure differently: winit has EventLoopError and OsError, wgpu has separate adapter and device errors, Taffy has TaffyError, ab_glyph has InvalidFont, and tiny-skia returns a bare Option. An application wants one error type that all of them convert into, so ? works everywhere. That is the small book-error crate every snippet depends on. Its core is an enum with a variant per source:
pub enum Error {
Io(std::io::Error),
Missing(String), // an Option that was None
Context { context: String, source: Box<Error> }, // "while doing X..."
Other(Box<dyn std::error::Error + Send + Sync>), // anything unlisted
#[cfg(feature = "winit")]
EventLoop(winit::error::EventLoopError),
#[cfg(feature = "wgpu")]
Adapter(wgpu::RequestAdapterError),
// ...one variant per framework, behind a Cargo feature of the same name
} The From impls are what make the ? operator convert automatically. A small macro keeps each one to a line, and each is behind its framework's feature, so a snippet only compiles the conversions it uses.
//! `From` impls: these are what make `?` work across crate boundaries.
use crate::Error;
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error::Io(e)
}
}
macro_rules! from {
($feature:literal, $ty:ty => $variant:ident) => {
#[cfg(feature = $feature)]
impl From<$ty> for Error {
fn from(e: $ty) -> Self {
Error::$variant(e)
}
}
};
}
from!("semver", semver::Error => Semver);
from!("glutin", glutin::error::Error => Gl);
from!("winit", winit::error::EventLoopError => EventLoop);
from!("winit", winit::error::OsError => Os);
from!("raw-window-handle", raw_window_handle::HandleError => Handle);
from!("wgpu", wgpu::RequestAdapterError => Adapter);
from!("wgpu", wgpu::RequestDeviceError => Device);
from!("taffy", taffy::TaffyError => Layout);
from!("vello", vello::Error => Render);
from!("ab-glyph", ab_glyph::InvalidFont => Font);
from!("slint", slint::PlatformError => Platform);
from!("tauri", tauri::Error => Tauri);
from!("iced", iced::Error => Iced);
from!("eframe", eframe::Error => Eframe);
// JavaScript exceptions cross the wasm boundary as `JsValue`, in both directions.
#[cfg(feature = "wasm")]
impl From<wasm_bindgen::JsValue> for Error {
fn from(v: wasm_bindgen::JsValue) -> Self {
Error::Js(v.as_string().unwrap_or_else(|| format!("{v:?}")))
}
}
#[cfg(feature = "wasm")]
impl From<Error> for wasm_bindgen::JsValue {
fn from(e: Error) -> Self {
wasm_bindgen::JsValue::from_str(&e.report())
}
} Conversion alone loses what you were doing. The Context trait adds that, and also does a second job: it turns an Option into an error. Library constructors such as tiny-skia's Pixmap::new return None on failure, and .context("could not allocate a 200x200 pixmap")? gives that None a message.
//! Adding context as an error travels up the call stack.
use crate::{Error, Result};
/// Add a human message to an error, or turn a `None` into an error.
pub trait Context<T> {
fn context(self, message: impl Into<String>) -> Result<T>;
}
impl<T, E: Into<Error>> Context<T> for std::result::Result<T, E> {
fn context(self, message: impl Into<String>) -> Result<T> {
self.map_err(|e| Error::Context { context: message.into(), source: Box::new(e.into()) })
}
}
impl<T> Context<T> for Option<T> {
fn context(self, message: impl Into<String>) -> Result<T> {
self.ok_or_else(|| Error::Missing(message.into()))
}
} Each layer adds what it was trying to do, and the original cause is kept. The demo below fails on purpose, and the output is what it really printed.
use book_error::{Context, Result};
// Each layer adds what *it* was trying to do; the original cause is kept.
fn read_config(path: &str) -> Result<String> {
Ok(std::fs::read_to_string(path)?)
}
fn start_app(path: &str) -> Result<()> {
let _config = read_config(path).context(format!("starting app with config {path}"))?;
Ok(())
}
fn main() {
// Only the outermost layer decides what to do with a failure.
if let Err(err) = start_app("/nonexistent/app.toml") {
eprintln!("error: {}", err.report());
}
} error: starting app with config /nonexistent/app.toml
caused by: I/O error: No such file or directory (os error 2)Some places cannot use ? at all, and the snippets show how each is handled. Event-loop callbacks return (), so the winit examples store the error in the app struct and exit the loop, and main returns it afterwards. Entry points that cannot return a Result, like Tauri's mobile entry or gpui's startup closure, report the error and exit at that one boundary. WebAssembly turns an Error into a JavaScript exception through a From<Error> for JsValue impl. Build scripts return Box<dyn Error>, which Cargo prints.
Two lessons from building it. Cargo resolves every optional dependency when it writes a lockfile, so Druid (gtk-rs 0.16 bindings) and Tauri (gtk-rs 0.18) could not both be features of one crate: they link the same native library. Druid therefore goes through Error::other. And raw-window-handle's HandleError only implements std::error::Error with its std feature on, which several snippets got by accident through winit or wgpu, until the glutin one did not. Still, unwrap() is not banned everywhere: it is fine in tests and for states that are genuinely impossible. A window library failing to start is not one of those.