Verox, the chainable Result type for Go that keeps sentinel-wrapped errors correctly attributed to the stage that actually produced them
Go's (value, error) return convention is good, but chaining several fallible steps together and wrapping each one's error with a sentinel for clean handling further up the call stack usually means either a lot of repeated if err != nil boilerplate, or bugs that are easy to miss: a later step's sentinel getting stamped onto an earlier step's error that a short-circuited chain never even reached, or a later step's side effects (a DB write, an API call) firing anyway because the short-circuit only happened after the call was already made. Verox's Res[T] handles both of these for you: Try, Map, and FlatMap never invoke a later step once an earlier one has failed, and WrapErr won't attribute a sentinel to an error that isn't actually the current stage's own.
Verox is built on Go 1.27's generic methods, so combinators like Map, Try, and FlatMap live directly on Res[T] instead of as free functions.
To install Verox in a repo, simply run
$ go get github.com/syke99/veroxThen you can import the package in any go file you'd like
import "github.com/syke99/verox"Start a chain with Try, passing in the (value, error) pair returned by any ordinary Go function:
res := verox.Try(fetchUser(id))Attach a sentinel error with .WrapErr() so callers further up the stack can check for it with errors.Is, regardless of how the underlying error was phrased:
res := verox.Try(fetchUser(id)).
WrapErr(ErrUserLookupFailed)Use .Or() to fall back to an alternate computation if the chain has
already failed — trying a backup data source, for instance. Its signature
is Or(f func() (T, error)) Res[T] — the same (value, error) shape as
any ordinary Go function, so a zero-arg function can be passed by name
directly, the same way validateUser was earlier. It's the mirror image
of the other combinators: Try/Map/FlatMap skip f when there's
already an error, while Or only calls f when there is one, and is
a no-op on success:
func fetchUserFromBackup() (User, error) {
// ...
}
res := verox.Try(fetchUser(id)).
Or(fetchUserFromBackup)Chain additional fallible steps with .Try(). Yes, the same name as the
package-level Try used to start the chain above — Go keeps these apart
without any ambiguity, since one is reached through the package
(verox.Try(...)) and the other through a receiver (res.Try(...)). Its
signature is Try[U any](f func(val T) (U, error)) Res[U] — f takes the
value currently held by the chain (T) and returns the same
(value, error) shape any ordinary Go function already returns, just with
a possibly different result type (U):
// validateUser matches the shape Try requires: func(T) (U, error). Here T
// and U both happen to be User, but they don't have to be — Try can change
// the type along with validating it, the same way fetchUser could just as
// easily have returned a different type than what comes out the other end
// of validateUser.
func validateUser(u User) (User, error) {
if u.Email == "" {
return User{}, errors.New("user has no email")
}
return u, nil
}
res := verox.Try(fetchUser(id)).
WrapErr(ErrUserLookupFailed).
Try(validateUser).
WrapErr(ErrUserInvalid)If fetchUser already failed, validateUser is never called — Try
short-circuits, so no step past a failure ever runs.
f's signature only ever has room for the one value the chain is
currently holding (T), so if a real step needs anything more — a config
struct, a DB handle, a logger, whatever — reach for a closure that
captures it. This is the same closure-over-captured-variables pattern
Go already uses everywhere else (sort.Slice, errgroup.Group.Go, HTTP
middleware), and it applies the same way to Map and FlatMap below:
func validateUserStrict(u User, cfg Config, db *DB) (User, error) {
if cfg.Strict && u.Email == "" {
return User{}, errors.New("strict mode: user has no email")
}
// ... use db for whatever additional lookups strict validation needs
return u, nil
}
res := verox.Try(fetchUser(id)).
WrapErr(ErrUserLookupFailed).
Try(func(u User) (User, error) {
return validateUserStrict(u, cfg, db)
}).
WrapErr(ErrUserInvalid)Use .Map() for a step that can't fail. Its signature is
Map[U any](f func(T) U) Res[U] — no error in the return, just a plain
value transform:
// toProfile matches the shape Map requires: func(T) U — a value in, a
// value out, nothing that can fail.
func toProfile(u User) Profile {
return Profile{
DisplayName: u.Name,
Avatar: u.AvatarURL,
}
}
res := verox.Try(fetchUser(id)).
WrapErr(ErrUserLookupFailed).
Try(validateUser).
WrapErr(ErrUserInvalid).
Map(toProfile)Use .FlatMap() for a step that's already built out of its own verox chain
and so already returns a Res[U], rather than a plain (value, error)
pair. Its signature is FlatMap[U any](f func(T) Res[U]) Res[U]. Reaching
for .Map() here instead would leave you with a Res[Res[U]] — a Res
nested inside a Res, needing an extra .Unwrap() just to get back to
the value you actually want. .FlatMap() flattens that away:
// loadSubscription matches the shape FlatMap requires: func(T) Res[U]. It
// already returns its own Res[Subscription], built from its own internal
// Try/WrapErr chain, instead of a plain (value, error) pair.
func loadSubscription(u User) verox.Res[Subscription] {
return verox.Try(billingClient.GetSubscription(u.ID)).
WrapErr(ErrSubscriptionLookupFailed)
}
res := verox.Try(fetchUser(id)).
WrapErr(ErrUserLookupFailed).
FlatMap(loadSubscription)Tap into the chain with .Peek()/.PeekErr() for logging or metrics without disturbing it, and check for specific errors mid-chain with .Is()/.As():
res = res.
Peek(func(p Profile) { log.Printf("resolved profile: %+v", p) }).
PeekErr(func(err error) { log.Printf("chain failed: %v", err) })
if res.Is(ErrUserInvalid) {
// handle invalid users specially
}Finally, call .Unwrap() to exit back to a plain Go (value, error) return:
func GetProfile(id string) (Profile, error) {
return verox.Try(fetchUser(id)).
WrapErr(ErrUserLookupFailed).
Try(validateUser).
WrapErr(ErrUserInvalid).
Map(toProfile).
Unwrap()
}.Unwrap() isn't the only way to exit a chain. .UnwrapOr(default)
returns the held value, or a fallback default if the chain failed —
useful when Unwrap's zero-value-on-error isn't what you actually want:
name := verox.Try(fetchUser(id)).
Map(func(u User) string { return u.Name }).
UnwrapOr("unknown").Fold() goes further, resolving both branches into a single value in one
call instead of checking an error after the fact. Its signature is
Fold[U any](onSuccess func(T) U, onFailure func(error) U) U:
status := verox.Try(fetchUser(id)).
Fold(
func(u User) string { return "found: " + u.Name },
func(err error) string { return "not found: " + err.Error() },
)This library was developed by Quinn Millican (@syke99)
This repo is under the MIT license, see LICENSE for details.