You know that sinking feeling. You’ve been working on a Rust project for months. Your error types are a tangled mess of Box, custom enums, and thiserror derives. Every new function adds another ? and you’re not even sure what it means anymore. But you keep going because Rust promised safety, right?
Here’s the uncomfortable truth: the ? operator isn’t making your code safer—it’s making it harder to understand, harder to change, and harder to debug. And the worst part? Everyone else seems to think it’s fine.
I’ve spent the last six months auditing Rust codebases, and I’ve seen the pattern over and over. Teams that faithfully use Result, Option, and ? end up with code that looks clean but collapses under real-world complexity. The problem isn’t Rust’s type system—it’s how we’ve been taught to use it.
The Hidden Coupling You Didn’t Notice
Take a typical function: fn read_config(path: &str) -> Result<Config, ConfigError>. Simple enough. But what happens when you call it inside another function that also returns a Result with a different error type? You reach for map_err or into() or a From impl. Suddenly your error types are tangled with your function signatures. Every time you add a new error variant, you might need to update half a dozen From implementations across your project.
This isn’t zero-cost. It’s zero-savings with a massive tax on future you.
You’ve been tricked into thinking that error handling is a type problem. It’s not. It’s a flow problem. The real leverage comes from separating error accumulation from decision logic. Think of it like logs: you don’t stop your program every time a log line gets written—you collect, then decide.
The Twist: Treat Errors Like Structured Logs
What if instead of propagating errors up and up, you just… collected them? A single channel that holds all errors, no matter where they come from. Your functions return Option or bool or nothing at all. The error is sent to a buffer. Later—maybe at the end of a request, or after a batch—you inspect the buffer and decide: retry, fail gracefully, or panic.
I saw this firsthand in a production Rust service that handled thousands of concurrent requests. The team stopped using ? for everything. They introduced a simple error channel that decoupled error reporting from error handling. The result? Their function signatures became clean, their error types unified, and their debugging went from “trace the chain of ?” to “dump the error buffer and see exactly what happened.”
You don’t need better error types. You need to stop conflating errors with control flow.
Why This Will Make People Angry (And Why That’s Good)
I know what you’re thinking: \”But Rust’s whole point is type safety! If I don’t use Result everywhere, I’m losing the guarantees!\” And that’s exactly the mindset that’s holding us back. Type safety is a tool, not a religion. The moment you make error handling a religious practice—where every fn must return Result—you’ve lost the plot.
The most robust Rust code I’ve ever seen uses Result sparingly. It uses Option when appropriate. It uses a collector pattern for errors that don’t need immediate handling. And it reserves the ? operator only for genuine early exits—not as a default escape hatch for every uncertain call.
If your codebase has more ? operators than function calls, you don’t have error handling. You have a leaky abstraction masquerading as safety.
What To Do Instead
Start small. Pick one module in your project. Remove all Result returns that don’t absolutely require early exit. Replace them with an error collector—just a Vec or a channel that aggregates errors. Call the unsafe stuff, push errors, and handle them at the boundary. Watch how your code transforms: less nesting, fewer match arms, clearer intent.
Then ask yourself: Did you lose any safety? Or did you just trade theoretical safety for practical clarity?
I’ve been on both sides. I’ve written the beautiful, type-safe error chains. And I’ve debugged them at 3 AM. Give me the collector pattern any day.
The best error handling is the kind you don’t think about. Not because you ignore errors—but because you’ve designed a system that separates ‘something went wrong’ from ‘what do we do about it.’
FAQ
Q: Doesn't this approach sacrifice Rust's safety guarantees?
A: No. You're still handling errors—just at a different point in the flow. The guarantees come from deciding how to respond, not from when you propagate. In fact, decoupling reduces the risk of accidentally swallowing errors or creating tangled match chains.
Q: What's the practical first step to apply this?
A: Take any module that returns Result everywhere. Change the functions to push errors to a shared collector (like a Vec or mpsc channel). Then handle all errors at the top level. You'll immediately see fewer type annotations and simpler function signatures.
Q: Isn't this just reinventing exceptions or Go-style error handling?
A: It's a middle ground. You get the explicit error collection of Rust without the boilerplate of propagating every variant. Unlike exceptions, the collector is visible and typed. Unlike Go, you can still use pattern matching on aggregated errors. It's a pragmatic blend, not a bastardization.