try is a small Go package that provides a flat, exception-style error-handling
pattern on top of Go's native error values.
Instead of repeating if err != nil { return err } after every fallible call,
you write linear code using Must* inside a Scope* block. Errors are still
values at the block boundary — Scope recovers Must-induced panics and
returns them as ordinary errors.
When a fallible call needs local context before it bubbles out, wrap the result
with Of*, call Wrap, Wrapf, or MapErr, then finish with either Must
inside a Scope or Result at a normal Go return boundary.
Go's explicit error handling is great for readability at function boundaries, but it pushes a lot of ceremony into the happy path:
a, err:=step1(in)
iferr!=nil {
return0, err
}
b, err:=step2(a)
iferr!=nil {
return0, err
}
c, err:=step3(b)
iferr!=nil {
return0, err
}
returnc, nilWith try the same flow becomes linear, while the function still returns an
error rather than panicking:
returntry.Scope1(func() int {
a:=try.Must1(step1(in))
b:=try.Must1(step2(a))
returntry.Must1(step3(b))
})go get github.com/go-board/try
- Errors stay values at the boundary.
Scope/Scope1..Scope5return a normalerror(alongside any returned values). Norecoverleaks into caller code. - Only
Mustpanics are captured. AMustfailure panics with an internalpanickedErrormarker.Scoperecovers only that marker; any other panic (nil dereference, out-of-range, third-party library panic) is re-panicked so real bugs are never silently swallowed. - Error identity is preserved. The wrapped error round-trips through
errors.Is/errors.As, including concrete types — see the example below. - Same-goroutine only. Panics from goroutines spawned inside
fare not captured — they will crash the program, which is the only safe default. - Zero values on failure. When a
Mustpanic is recovered byScope1..Scope5, the returned values are the zero values of their types; onlyerris meaningful. Always checkerrbefore using the outputs.
panickedError implements Unwrap, so the original error survives the
panic → recover round-trip:
varErrEmpty=errors.New("empty input")
_, err:=try.Scope1(func() int {
returntry.Must1(parse("")) // returns ErrEmpty
})
errors.Is(err, ErrEmpty) // truevarce*customErrerrors.As(err, &ce) // true if ErrEmpty wraps a *customErrOf* wrappers preserve the same identity when adding context:
_, err:=try.Scope1(func() int {
returntry.Of1(parse(input)).Wrap("parse input").Must()
})
errors.Is(err, ErrEmpty) // trueScope blocks may be nested. The innermost Scope recovers a Must panic
first, so an inner failure does not propagate to an outer Scope unless the
inner Scope itself re-panics (which it never does for Must panics).
err:=try.Scope(func() {
try.Must(outerStep()) // if this fails, outer Scope catches itiferr:=try.Scope(innerStep); err!=nil {
// inner failure handled here; outer Scope is unaffected
}
})- Don't call
Mustin goroutines started insidef. Their panics escapeScopeand crash the program. If you need concurrent fallible work, run each in its ownScopeand collect errors explicitly. MustoutsideScopecrashes the program. This is intentional (liketemplate.Must); only use it for truly unrecoverable invariants.- Don't inspect outputs when
err != nil. They are zero values, not partial results. - Foreign panics are not converted. A nil dereference or a panic from a
third-party library inside
fis re-panicked with the original panic value, not returned aserr.
Must panics when err != nil. Must1..Must5 additionally carry through
1–5 return values on the happy path.
funcMust(errerror)
funcMust1[Aany](vA, errerror) AfuncMust2[A, Bany](v1A, v2B, errerror) (A, B)
funcMust3[A, B, Cany](v1A, v2B, v3C, errerror) (A, B, C)
funcMust4[A, B, C, Dany](v1A, v2B, v3C, v4D, errerror) (A, B, C, D)
funcMust5[A, B, C, D, Eany](v1A, v2B, v3C, v4D, v5E, errerror) (A, B, C, D, E)Outside a Scope, a Must panic propagates and crashes the program, similar
to template.Must.
Assert and Assertf are for preconditions that should bubble out as errors
from a Scope.
varErrEmpty=errors.New("empty input")
err:=try.Scope(func() {
try.Assert(input!="", ErrEmpty)
try.Assertf(limit>0, "invalid limit %d", limit)
})funcAssert(okbool, errerror)
funcAssertf(okbool, formatstring, args...any)
varErrAssertionFailederrorIf Assert fails with a nil error, it uses ErrAssertionFailed so the failed
assertion is not silently ignored. Outside a Scope, failed assertions panic
like Must.
Scope runs f and returns any Must-induced panic as an err.
Scope1..Scope5 do the same when f returns 1–5 values.
funcScope(ffunc()) (errerror)
funcScope1[Aany](ffunc() A) (outA, errerror)
funcScope2[A, Bany](ffunc() (A, B)) (out1A, out2B, errerror)
funcScope3[A, B, Cany](ffunc() (A, B, C)) (out1A, out2B, out3C, errerror)
funcScope4[A, B, C, Dany](ffunc() (A, B, C, D)) (out1A, out2B, out3C, out4D, errerror)
funcScope5[A, B, C, D, Eany](ffunc() (A, B, C, D, E)) (out1A, out2B, out3C, out4D, out5E, errerror)If a non-Must panic occurs inside f, Scope re-panics with the original
value so genuine bugs surface normally.
Go does not have Python-style bare re-raise. The re-panic keeps the original
panic value, but runtime output or outer recover middleware may show the
Scope re-panic frame above the original business frames.
Of/Of1..Of5 capture a normal Go result so the error can be wrapped or
mapped before the final Must or Result.
funcOf(errerror) TryfuncOf1[Aany](v1A, errerror) Try1[A]
funcOf2[A, Bany](v1A, v2B, errerror) Try2[A, B]
funcOf3[A, B, Cany](v1A, v2B, v3C, errerror) Try3[A, B, C]
funcOf4[A, B, C, Dany](v1A, v2B, v3C, v4D, errerror) Try4[A, B, C, D]
funcOf5[A, B, C, D, Eany](v1A, v2B, v3C, v4D, v5E, errerror) Try5[A, B, C, D, E]Each Try* value supports the same chainable methods:
MapErr(func(error) error) Try*Wrap(messagestring) Try*Wrapf(formatstring, args...any) Try*Must() values...Result() values..., errorWrap and Wrapf add context while preserving the original error for
errors.Is and errors.As. MapErr is for custom error types or policies.
If MapErr returns nil for a non-nil error, the original error is kept so an
existing failure is not swallowed accidentally.
Use Must inside Scope when you want linear control flow. Use Result when
you are already at a normal Go return boundary.
returntry.Scope1(func() int {
cfg:=try.Of1(readConfig(path)).Wrapf("read config %q", path).Must()
returntry.Of1(parseConfig(cfg)).Wrap("parse config").Must()
})funcload(pathstring) (Config, error) {
returntry.Of1(readConfig(path)).Wrapf("read config %q", path).Result()
}The tryerr subpackage adds stable codes, structured attributes, and a
backtrace while keeping Go's native error chain intact.
returntry.Of1(readConfig(path)).
MapErr(func(errerror) error {
returntryerr.Wrap(
err,
"read config",
tryerr.WithCode(1001),
tryerr.WithAttr("path", path),
)
}).
Result()tryerr.Wrap(nil, ...) returns nil, so it is safe to use in ordinary
propagation paths. The default code is -1; override it with
tryerr.WithCode. Code 0 is reserved for unset values and is skipped by
tryerr.Code. tryerr.WithAttrs copies its input map, and tryerr.Attrs
returns a fresh merged map where outer errors keep precedence on key collisions.
funcNew(messagestring, opts...Option) errorfuncWrap(causeerror, messagestring, opts...Option) errorfuncWithCode(codeint) OptionfuncWithAttr(key, valuestring) OptionfuncWithAttrs(attrsmap[string]string) OptionfuncWithStackDepth(depthint) OptionfuncWithStackSkip(skipint) OptionfuncCode(errerror) intfuncAttrs(errerror) map[string]stringfuncStackFrames(errerror) iter.Seq[runtime.Frame]Use errors.Is / errors.As for the original cause and tryerr.Code,
tryerr.Attrs, or tryerr.StackFrames for structured metadata.
StackFrames returns iter.Seq[runtime.Frame], so callers can range over
resolved frames without handling raw program counters. New and Wrap capture
one caller frame by default; use tryerr.WithStackDepth to capture more and
tryerr.WithStackSkip to skip additional caller frames. Stack skip is relative
to the caller of New or Wrap. Error() includes the code and first captured
frame in the returned text, for example read config [code=1001] [...].
The extractor functions primarily read tryerr.Error values. They also accept
small same-shape providers in the chain: interface{ Code() int },
interface{ Attrs() map[string]string }, and
interface{ StackFrames() iter.Seq[runtime.Frame] }.
package main
import (
"errors""fmt""github.com/go-board/try"
)
varErrEmpty=errors.New("empty input")
funcparse(sstring) (int, error) {
ifs=="" {
return0, ErrEmpty
}
returnlen(s), nil
}
funcdouble(sstring) (int, error) {
returntry.Scope1(func() int {
n:=try.Must1(parse(s)) // converted to err by Scope1returnn*2
})
}
funcmain() {
out, err:=double("hello")
fmt.Println(out, err) // 10 <nil>_, err=double("")
fmt.Println(errors.Is(err, ErrEmpty)) // true
}- Good fit: short, linear setup or transformation pipelines where the only recovery strategy is "bubble the error up", optionally with local context.
- Not a good fit: request handlers that need to map errors to status codes,
retry loops, or any path where you actually branch on specific errors —
plain
if err != nilis clearer there.
Requires Go 1.24+.
See LICENSE.