DEV Community

Cover image for Guide to go-opera: Better error handling in Go, but less boilerplate
ColaFanta
ColaFanta

Posted on

Guide to go-opera: Better error handling in Go, but less boilerplate

Error handling in Go has been debated for years. Many developers appreciate the explicit and imperative style of if err != nil, and there is real value in that clarity. But that same style also limits how expressive code can be. As the control flow becomes more complex, the amount of boilerplate grows quickly, and the actual business logic gets buried under repetitive error checks.

This is exactly the problem I wanted to improve with go-opera: keep Go's explicitness, but reduce the friction.

In this article, I will first look at a few common pain points in Go's conventional error handling, then introduce a Result-based approach with go-opera.

Common pitfalls

Redundant empty value

When a function fails, Go still expects you to return an empty value together with the error:

func loadUser(id string) (User, error) {
    if id == "" {
        return User{}, errors.New("empty id")
    }
    return User{ID: id}, nil
}
Enter fullscreen mode Exit fullscreen mode

The zero value is usually just filler. It adds noise, not meaning.

Unable to use return value directly

Go supports multiple return values, but it does not let you pass them directly as arguments to another function.

func parsePort() (int, error) {
    return strconv.Atoi("8080")
}

func openServer(port int) error {
    return nil
}

func main() {
    // this does not compile
    openServer(parsePort())
}
Enter fullscreen mode Exit fullscreen mode

You always have to unpack first. That hurts composition.

Error name collision

Once a function grows, repeated err, err2, err3, or shadowed err variables make the code harder to read and maintain:

func tooManyErrors() error {
    val1, err1 := errFunc1()
    if err1 != nil {
        return fmt.Errorf("step 1 failed: %w", err1)
    }

    val2, err2 := errFunc2(val1)
    if err2 != nil {
        return fmt.Errorf("step 2 failed: %w", err2)
    }

    val3, err3 := errFunc3(val2)
    if err3 != nil {
        return fmt.Errorf("step 3 failed: %w", err3)
    }

    if err4 := errFunc4(val3); err4 != nil {
        return fmt.Errorf("step 4 failed: %w", err4)
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Shadowing err has a similar problem:

func process() error {
    data, err := readFile()
    if err != nil {
        return err
    }

    if err := validate(data); err != nil {
        return err
    }

    if err := save(data); err != nil {
        return err
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

This is idiomatic, but not cheap to read. As steps grow, the function becomes error bookkeeping.

Introducing go-opera

go-opera is a small Go package that brings a more expressive error handling model through Result, Option, and Do notation.

The idea is inspired by patterns from Rust and functional programming libraries like EffectTS, adapted to fit Go instead of fighting it. The goal is not to hide errors but to make error propagation easier to read, easier to compose, and less repetitive.

At the center of the package is Result[T], which represents either a successful value or an error.

func parsePort(raw string) opera.Result[int] {
    port, err := strconv.Atoi(raw)
    if err != nil {
        return opera.Err[int](err)
    }
    return opera.Ok(port)
}

res := parsePort("42").OrPanic()
Enter fullscreen mode Exit fullscreen mode

The real benefit appears when your own functions return Result[T] and compose with opera.Do(...).

Accumulative error handling with Do, Easy function chain with Yield

In conventional Go, each step must unwrap (value, error) and then wrap the error again to return it. If your own functions return Result[T], opera.Do lets you compose them directly and propagate failures to the exact point where you want to handle them.

func createOrder(userID string, amount int) opera.Result[string] {
    return opera.Do(func() string {
        user := findUser(userID).Yield()
        account := loadAccount(user.AccountID).Yield()
        validateBalance(account, amount).Yield()
        paymentID := charge(account, amount).Yield()
        return fmt.Sprintf("order created for %s with payment %s", user.Name, paymentID)
    })
}
Enter fullscreen mode Exit fullscreen mode

Yield is like ? in Rust and yield* in EffectTS. Every .Yield() means: “give me the value, or stop here and bubble the error out of the surrounding Do block.”

Without Yield, you would need temporary variables and an if err != nil after every single step. With Yield, each step can feed the next one directly, so the code reads more like a pipeline than a chain of guards.

That keeps the happy path linear. You read the logic from top to bottom, and error propagation happens automatically.

Error recovery with Catch

Not every error should abort the whole flow. Sometimes you want a sensible fallback.

go-opera supports this with Catch and CatchIs.

func findDisplayName(userID string) opera.Result[string] {
    return opera.Do(func() string {
        user := loadUser(userID).Yield()
        return user.DisplayName
    }).
        Catch(func(err error) opera.Result[string] {
            if errors.Is(err, ErrUserNotFound) {
                return opera.Ok("guest")
            }
            return opera.Err[string](err)
        })
}
Enter fullscreen mode Exit fullscreen mode

If you only need to recover from a known error with a fixed fallback value, CatchIs is even shorter:

func checkLogin(user, password string) opera.Result[bool] {
    return opera.Do(func() bool {
        u := repo.FindUser(user).Yield()
        verifyPassword(u.Password, password).Yield()
        return true
    }).
        CatchIs(ErrUserNotFound, false).
        CatchIs(bcrypt.ErrMismatchedHashAndPassword, false)
}
Enter fullscreen mode Exit fullscreen mode

This keeps recovery close to the boundary where you actually want to recover, instead of scattering special-case checks through the whole function body.

Compatible with existing Go conventions

One thing I care about is that go-opera works with normal Go code instead of forcing you into a closed ecosystem.

In other words, I prefer writing internal application code with Result[T], and only switching back to (value, error) when I need to integrate with conventional Go APIs.

If you already have a function that returns (T, error), you can wrap it with Try:

res := opera.Try(os.ReadFile("config.json"))
Enter fullscreen mode Exit fullscreen mode

If you want to go back to normal Go style at the boundary of your function, call Get:

func loadMessage() ([]byte, error) {
    return opera.Do(func() []byte {
        body := opera.Try(os.ReadFile("message.txt")).Yield()
        return bytes.TrimSpace(body)
    }).Get()
}
Enter fullscreen mode Exit fullscreen mode

So the migration path is incremental. You can adopt Result only where it helps, and still expose the usual (value, error) API to the rest of your application.

Before and after

Example 1: straight-line business logic

Before:

func register(email string) (User, error) {
    validatedEmail, err := validateEmail(email)
    if err != nil {
        return User{}, fmt.Errorf("validate email: %w", err)
    }

    existingUser, err := findUserByEmail(validatedEmail)
    if err != nil {
        if !errors.Is(err, ErrUserNotFound) {
            return User{}, fmt.Errorf("check existing email: %w", err)
        }
    } else {
        if existingUser.ID != "" {
            return User{}, fmt.Errorf("email already registered: %w", ErrEmailTaken)
        }
    }

    createdUser, err := createUser(validatedEmail)
    if err != nil {
        return User{}, fmt.Errorf("create user: %w", err)
    }

    if err := sendWelcomeEmail(createdUser); err != nil {
        return User{}, fmt.Errorf("send welcome email: %w", err)
    }

    return createdUser, nil
}
Enter fullscreen mode Exit fullscreen mode

After:

func register(email string) opera.Result[User] {
    return opera.Do(func() User {
        validated := validateEmail(email).Yield()
        ensureEmailAvailable(validated).Yield()
        created := createUser(validated).Yield()
        sendWelcomeEmail(created).Yield()
        return created
    })
}
Enter fullscreen mode Exit fullscreen mode

The business flow is easier to see because the function is no longer dominated by repetitive branching.

Example 2: handling a recoverable error

Before:

func checkLogin(user, password string) (bool, error) {
    u, err := repo.FindUser(user)
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            return false, nil
        }
        return false, fmt.Errorf("find user: %w", err)
    }

    if u.Locked {
        return false, nil
    }

    if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
        if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
            return false, nil
        }
        return false, fmt.Errorf("verify password: %w", err)
    }

    if err := markLastLogin(u.ID); err != nil {
        return false, fmt.Errorf("update last login: %w", err)
    }

    if !u.Active {
        return false, nil
    }

    return true, nil
}
Enter fullscreen mode Exit fullscreen mode

After:

func checkLogin(user, password string) opera.Result[bool] {
    return opera.Do(func() bool {
        u := repo.FindUser(user).Yield()
        ensureLoginAllowed(u).Yield()
        verifyPassword(u.Password, password).Yield()
        markLastLogin(u.ID).Yield()
        return true
    }).
        CatchIs(ErrUserNotFound, false).
        CatchIs(ErrUserLocked, false).
        CatchIs(bcrypt.ErrMismatchedHashAndPassword, false)
}
Enter fullscreen mode Exit fullscreen mode

This version makes the success path obvious, while still keeping error recovery explicit.

Conclusion

Go's traditional error handling is explicit, simple, and proven. But in larger flows, it also creates friction: placeholder zero values, repetitive branching, and a lot of code that exists only to move errors around.

go-opera is my attempt to keep the good part of Go's error model while reducing that friction. With Result, Do, Yield, and Catch, you can keep most of your internal code in a composable form and only convert back to (value, error) when you need to integrate with conventional Go APIs.

It is not meant to replace every if err != nil in Go. It is a tool for the parts of your code where composition and readability matter most.

Give it a try:

Top comments (0)