Skip to content
syke99Public

About

A chainable Result type for Go, with sentinel errors that stay correctly attributed to the stage that produced them.

Resources

Stars

11 stars

Watchers

0 watching

Forks

Latest commit

 

History

19 Commits

Folders and files

Repository files navigation

Verox

Coverage Go Reference

LICENSE

Verox, the chainable Result type for Go that keeps sentinel-wrapped errors correctly attributed to the stage that actually produced them

What problem does Verox solve?

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.

How do I use Verox?

Installing

To install Verox in a repo, simply run

$ go get github.com/syke99/verox

Then you can import the package in any go file you'd like

import "github.com/syke99/verox"

Basic usage

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() },
	)

Who?

This library was developed by Quinn Millican (@syke99)

License

This repo is under the MIT license, see LICENSE for details.

About

A chainable Result type for Go, with sentinel errors that stay correctly attributed to the stage that produced them.

Resources

Stars

11 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages