I spent the last ten years mostly writing Go, and when I started working in Rust seriously this year I had an immediate reaction to Result<T, E> and Option<T>: this is a lot of ceremony for something that if err != nil does in three characters. I grumbled about it for roughly two weeks, and then gradually stopped, and now I actually prefer it. Here’s what changed my mind.

The first shift was the ? operator. It looks like sugar, but it’s doing something subtly different from Go’s if err != nil { return err }. It propagates the error, applying From::from conversions if the return type’s error is different. So you can freely mix error types as long as there’s an impl:

fn load_user(id: UserId) -> Result<User, AppError> {
    let row = db::fetch_user(id)?;              // returns Result<Row, db::Error>
    let user = serialize::parse_user(&row)?;    // returns Result<User, serialize::Error>
    Ok(user)
}

In Go, I’d wrap each error explicitly:

func LoadUser(id UserID) (User, error) {
    row, err := db.FetchUser(id)
    if err != nil {
        return User{}, fmt.Errorf("fetch user: %w", err)
    }
    user, err := serialize.ParseUser(row)
    if err != nil {
        return User{}, fmt.Errorf("parse user: %w", err)
    }
    return user, nil
}

For a long time I thought the Go version was clearer. You see each error site, you know exactly where each error comes from, and the wrapping tells a story. But after writing real Rust code, I realized the Go version is more verbose AND less informative, because those wraps are me manually doing what From would do automatically — plus, my wrap messages tend to drift from the actual code. I’ve fixed three bugs in Go services where the error message said “parse user” but the actual failing function was “fetch permissions” because someone had refactored.

In Rust, the From impl sits next to the error type and the conversion is enforced by the compiler. You can’t get the wrapping wrong because there isn’t any; the conversion happens at the point of propagation.

The second shift was Option combinators. When I started, match statements on Option felt bloated:

let name = match user.name {
    Some(n) => n,
    None => "anonymous".to_string(),
};

Compared to Go’s user.Name (where the zero value is the empty string), that looked terrible. But after a couple of months, I started using the combinator methods and now I like them:

let name = user.name.clone().unwrap_or_else(|| "anonymous".to_string());
let upper = user.name.as_deref().map(str::to_uppercase);
let first = users.iter().find(|u| u.is_admin).map(|u| &u.id);

and_then, or_else, unwrap_or, map, filter, as_ref, as_deref, take, replace — each of them does a specific thing, and once you know them, they compose in ways that Go can only dream of. In Go I write:

var firstID string
for _, u := range users {
    if u.IsAdmin {
        firstID = u.ID
        break
    }
}
if firstID == "" {
    // no admin
}

In Rust I write:

if let Some(id) = users.iter().find(|u| u.is_admin).map(|u| &u.id) {
    // use id
}

Both work. The Rust version is clearer about intent. The Go version is clearer about the state machine. I’ve grown to think the “clearer about intent” version is better for my brain, but reasonable people can disagree.

The third shift was realizing that Option is the right way to handle nullability. In Go, nil pointers and zero values do double duty — nil means “not there,” but the zero value of a struct also means “not there,” and you often can’t tell which you’re dealing with. In Rust, Option::None is explicit. There’s no “zero value for User” that silently pretends to be a real user. A function that returns a User cannot return nothing. A function that returns Option<User> can, and the compiler makes every caller handle both cases.

This isn’t a minor point. I’ve spent real hours in Go code trying to figure out whether a struct field with value "" means “the user’s name is empty” or “the name was never set.” In Rust, name: Option<String> makes the answer obvious, and name: String guarantees the name exists.

A few specific patterns I’ve come to love:

// Convert Option<Result<T, E>> into Result<Option<T>, E>
let parsed = maybe_str.map(|s| s.parse::<i32>()).transpose()?;

// Chain a fallback
let cached = cache.get(&key).or_else(|| db.fetch(&key)).ok_or(NotFound)?;

// Take an owned Option out of a struct, leaving None behind
let old = std::mem::take(&mut self.buffered);

// Only run code if Some
if let Some(handle) = self.handle.take() {
    handle.abort();
}

I don’t think Rust’s error handling is perfect. thiserror and anyhow still feel like workarounds for things the language should do natively, and there’s a real tension between “specific, typed errors for library authors” and “just shove everything in anyhow::Error for application code.” I’ve landed on using thiserror at library boundaries and anyhow inside application crates. This matches most of the Rust ecosystem.

If you’re a Go person coming to Rust and bouncing off the error handling, give it a few months. The ? operator and the combinators aren’t ceremony for ceremony’s sake; they’re a clear win once you internalize them. But you have to write enough code to form the reflex.

For more on async ergonomics in the same style, see the post on Result/Option in async contexts. They compose well with .await once you get the hang of it.