Declare your errors once, instantiate them everywhere.
Falta is a tiny error package for Go. It gives you error factories: you declare the shape of an error at the package level — where it's reviewable, greppable, and matchable — and fill in the details at the call site.
var ErrUserNotFound = falta.Newf("user store: no user with id %d")
// ... 40 files later
return ErrUserNotFound.New(id)Go makes you pick one of two bad options.
Option 1: fmt.Errorf at the call site. You get great context, but the error's identity is
a string that exists in exactly one place. The same logical failure gets worded four different
ways across the codebase, nobody can errors.Is against it, and callers end up matching on
substrings.
// in three different files, for the same failure
return fmt.Errorf("no user %d", id)
return fmt.Errorf("user %d not found in store", id)
return fmt.Errorf("failed to find user: id=%d", id)Option 2: sentinel errors. They're matchable, but they're frozen strings. To attach the ID you have to wrap, which means the message still gets rewritten at every call site — so you're back to option 1 with an extra step.
var ErrNotFound = errors.New("not found")
return fmt.Errorf("user %d: %w", id, ErrNotFound) // message still ad hocFalta collapses the two. A factory is a matchable identity and a message template. One
declaration gives you consistent wording everywhere and errors.Is for free.
go get github.com/a20r/faltaRequires Go 1.21 or newer. The package itself imports only the standard library — testify
is a test-only dependency.
package main
import (
"errors"
"fmt"
"github.com/a20r/falta"
)
// Declare your package's errors in one place.
var (
ErrUserNotFound = falta.Newf("user store: no user with id %d")
ErrStoreClosed = falta.NewError("user store: already closed")
)
type Store struct {
users map[int]string
closed bool
}
func (s *Store) Get(id int) (string, error) {
if s.closed {
return "", ErrStoreClosed
}
name, ok := s.users[id]
if !ok {
return "", ErrUserNotFound.New(id)
}
return name, nil
}
func main() {
store := &Store{users: map[int]string{1: "alex"}}
_, err := store.Get(42)
fmt.Println(err) // user store: no user with id 42
fmt.Println(errors.Is(err, ErrUserNotFound)) // true
}The message reads the way it would if you'd written fmt.Errorf by hand, and the caller can
still branch on the error without string matching.
Falta has four constructors. Pick based on how the error's data is shaped.
The workhorse. Takes a format string, returns a factory whose New takes the arguments.
var ErrCannotOpenFile = falta.Newf("config: cannot open %s (mode %o)")
err := ErrCannotOpenFile.New("app.yaml", 0644)
// config: cannot open app.yaml (mode 644)When the error is about a domain object, hand it the object. The format string is a
text/template, so fields are named at the declaration
instead of positional at the call site.
type Circle struct {
Radius float64
}
var ErrInvalidCircle = falta.New[Circle]("invalid circle: radius ({{.Radius}}) <= 0")
err := ErrInvalidCircle.New(Circle{Radius: -1})
// invalid circle: radius (-1) <= 0Adding a field to the message later is a one-line change at the declaration — no call site has to be touched to keep passing the right number of positional arguments.
Same idea, for when the fields don't justify a struct. falta.M is just map[string]any.
var ErrCallFailed = falta.NewM("call failed: [code={{.code}}] {{.message}}")
err := ErrCallFailed.New(falta.M{
"code": 503,
"message": "Bad Gateway",
})
// call failed: [code=503] Bad GatewayFor errors with nothing to interpolate. It returns an error value directly, not a factory.
var ErrStoreClosed = falta.NewError("user store: already closed")
return ErrStoreClosedBoth the falta error and the underlying cause stay reachable through errors.Is.
err := ErrCannotOpenFile.New("app.yaml").Wrap(osErr)
// config: cannot open app.yaml: open app.yaml: no such file or directory
errors.Is(err, ErrCannotOpenFile) // true
errors.Is(err, os.ErrNotExist) // trueFor the "why this time" that doesn't belong in the declaration. Annotating does not change the error's identity.
err := ErrRequestFailed.New("/v1/users").Annotate("retry budget exhausted")
// http: request to /v1/users failed: retry budget exhausted
errors.Is(err, ErrRequestFailed) // trueThe pattern falta was really written for. One deferred line at the top of a function wraps whatever error that function returns, from any return site, without touching them.
func LoadRecord(id int) (record string, err error) {
defer ErrLoadFailed.New(id).Capture(&err)
conn, err := pool.Acquire()
if err != nil {
return "", err // becomes: store: cannot load record 7: <pool error>
}
defer conn.Release()
return conn.QueryRecord(id) // and so does this one
}Two requirements: the error return must be named (err error), and Capture must be
called with defer. It's a no-op when the function returns nil.
This is what keeps error messages consistent as a function grows. New early returns get the same wrapping automatically, so nobody has to remember the house style for this function's errors — it's stated once, at the top.
Newf and NewM factories can be extended, which appends a second template to the first.
Use it when a general error sometimes has a more specific explanation.
var (
ErrCallFailed = falta.NewM("call failed: [code={{.code}}] {{.message}}")
ErrCallFailedWithReason = ErrCallFailed.Extend(falta.NewM("because {{.reason}}"))
)
err := ErrCallFailedWithReason.New(falta.M{
"code": 503,
"message": "Bad Gateway",
"reason": "the upstream is down",
})
// call failed: [code=503] Bad Gateway because the upstream is downFalta errors are ordinary Go errors. errors.Is, errors.As, and errors.Unwrap all work,
including through a fmt.Errorf("...: %w", err) chain.
var f falta.Falta
if errors.As(err, &f) {
cause := f.Unwrap() // the error passed to Wrap, or nil
}Matching is by the factory's declaration string, not by the data interpolated into it. Two errors from the same factory match no matter what arguments they were built with, and an error matches the factory that built it.
errors.Is(ErrUserNotFound.New(1), ErrUserNotFound.New(2)) // true
errors.Is(ErrUserNotFound.New(1), ErrUserNotFound) // true
errors.Is(ErrUserNotFound.New(1), ErrStoreClosed) // falseBe aware of what that does not mean. Falta compares format strings and, failing that, falls back to comparing rendered messages — it does not compare factory instances. So two separately declared factories that share a format string are interchangeable, and any error whose message happens to equal a falta error's message will match it:
a := falta.Newf("boom: %s")
b := falta.Newf("boom: %s") // a distinct factory, identical declaration
errors.Is(a.New("x"), b.New("y")) // true — same format string
errors.Is(a.New("x"), errors.New("boom: x")) // true — same rendered messageIn practice this is rarely a problem: give each error its own message, the way you would anyway, and matching behaves the way you expect. It matters if you were counting on two same-worded errors in different packages staying distinguishable — they won't be.
NewErrorandAnnotatepanic on format verbs. Both take literal strings, so a stray%sis a bug — falta reports it loudly (NewErrorat declaration,Annotateat the call site) rather than emitting%!s(MISSING)into your logs. A bare%, as in100% full, is fine.falta.New[T]panics on a bad template — sometimes at call time. A template that doesn't parse panics at declaration, by design: a broken error message should not first surface during an incident. But a template that parses and references a field the value doesn't have ({{.Missing}}) only panics whenNewruns it.NewMis looser still: a missing map key renders as<no value>instead of failing.- Calling
New()with no arguments returns the raw format string as the error message. That's the intended behavior forNewError-style use of a factory, but it means a forgotten argument shows up as a literal%sor{{.Field}}rather than a compile error. Extendpanics on mismatched factory kinds. ANewffactory can only be extended by anotherNewffactory, and aNew[T]/NewMfactory only by one of the same type.
go test ./... # tests and runnable examples
go test -race -cover ./... # what CI runs
gofmt -l . # must print nothing
golangci-lint run ./... # config in .golangci.ymlCI runs the test suite against Go 1.21 through 1.24, plus lint, gofmt, and a go mod tidy
check. The examples in this README are backed by runnable
example tests, so they're verified on every commit.