Errors with kinds for Go 1.13+.
$ go get github.com/JosiahWitt/erk
Erk allows you to create errors that have a kind, message template, and params.
Since Erk supports Go 1.13+ errors.Is
, it is easier to test errors, especially errors that contain parameters.
Erk is quite extensible by leveraging the fact that kinds are struct types. For example, HTTP status codes, distinguishing between warnings and errors, and more can easily be embedded in kinds. See advanced kinds for some examples.
The name "erk" comes from "errors with kinds". Erk is also a play on irk, since errors can be annoying to deal with. Hopefully Erk makes them less irksome. 😄
Error kinds are struct types that implement the Kind
interface.
Typically the Kind
interface is satisfied by embedding a default kind, such as erk.DefaultKind
.
It is recommended to define a default kind for your app or package.
Example:
type ErkTableMissing struct { erk.DefaultKind }
Error messages are text templates, which allows referencing params by name.
Since params are stored in map, this is done by using the {{.paramName}}
notation.
Example:
"table {{.tableName}} does not exist"
A few functions in addition to the built in template functions have been added.
-
type
: Returns the type of the param. It is equivalent tofmt.Sprintf("%T", param)
Example:
{{type .paramName}}
-
inspect
: Returns more details for complex types. It is equivalent tofmt.Sprintf("%+v", param)
Example:
{{inspect .paramName}}
Template functions can be extended by overriding the TemplateFuncsFor
method on your default kind.
Params allow adding arbitrary context to errors. Params are stored as a map, and can be referenced in templates.
Other errors can be wrapped into Erk errors using the erk.Wrap
, erk.WrapAs
, and erk.WrapWith
, functions.
(I recommend defining errors as public variables, and avoid using erk.Wrap
.)
The wrapped error is stored in the params by the err
key.
Thus, templates can reference the error they wrap by using {{.err}}
.
Use errors.Unwrap
to return the original error.
Errors can be grouped using the erg
package.
Errors are appended to the error group as they are encountered.
Be sure to conditionally return the error group by calling erg.Any
, otherwise a non-nil error group with no errors will be returned.
See the example below.
Since Erk supports Go 1.13+ errors.Is
, testing errors is straightforward.
This is especially helpful for comparing errors that leverage parameters, since the parameters are ignored.
(Usually you just want to test a certain error was returned from the function, not that the error is assembled correctly.)
Example:
errors.Is(err, mypkg.ErrTableDoesNotExist)
returnstrue
only if theerr
ismypkg.ErrTableDoesNotExist
When returning an Erk error from a mock, most of the time the required template parameters are not critical to the test.
However, if the code being tested uses errors.Is
, and strict mode is enabled, simply returning the error from the mock will result in a panic.
Example:
someMockedFunction.Returns(store.ErrItemNotFound)
might panic
Thus, the erkmock
package exists to support returning errors from mocks without setting the required parameters.
You can create a mocked error From
an existing Erk error, or For
an error kind.
Example:
someMockedFunction.Returns(erkmock.From(store.ErrItemNotFound))
does not panic
By default, strict mode is not enabled.
Thus, if errors are encountered while rendering the error (eg. invalid template), the unrendered template is silently returned.
If parameters are missing for the template, <no value>
is used instead.
This makes sense in production, as an unrendered template is better than returning a render error.
However, when testing or in development mode, it might be useful for these types of issues to be more visible.
Strict mode causes a panic when it encounters an invalid template or missing parameters.
It is automatically enabled in tests, and can be explicitly enabled or disabled using the ERK_STRICT_MODE
environment variable set to true
or false
, respectively.
It can also be enabled or disabled programmatically by using the erkstrict.SetStrictMode
function.
When strict mode is enabled, calls to errors.Is
will also attempt to render the error. This is useful in tests.
Errors created with Erk can be directly marshaled to JSON, since the MarshalJSON
method is present.
Internally, this calls erk.Export
, followed by json.Marshal
.
If you want to customize how errors are marshalled to JSON, simply write your own function that uses erk.Export
and modifies the exported error as necessary before marshalling JSON.
If not all errors in your application are guaranteed to be
erk
errors, callingerk.Export
before marshalling to JSON will ensure each error is explicitly converted to anerk
error.
If you would like to export the errors as JSON, and return the error kind as the error type, see
erkjson
. Using the error kind as the exported error type is useful for something like AWS Step Functions, which allows defining retry policies based on the type of the returned error.
Since error kinds are struct types, they can embed other structs. This allows quite a bit of flexibility.
For example, you could create an erkwarning
package that defines a struct with an IsWarning() bool
method.
Then, you can use an interface to check for that method, and if the method returns true
, log the error instead of returning it to the client.
This would work well when coupled with erg
.
Any error kind that should be a warning simply needs to embed the struct from erkwarning
.
This allows all errors to bubble to the top, simplifying how warnings and errors are distinguished.
Something similar can also be done for HTTP statuses, allowing status codes to be determined on the error kind level.
See erkhttp
for an implementation.
It is recommended to define a default error kind for your app or package that embeds erk.DefaultKind
.
Then, every error kind for your app or package can embed that default error kind.
This allows easily overriding or adding properties to the default kind.
Two recommended names for this shared package are erks
or errkinds
.
Example:
type Default struct { erk.DefaultKind }
There are two recommended ways to define your kinds:
-
Define your error kind types in each package near the errors themselves.
This allows
erk.Export
orerk.GetKindString
to contain which package the error kind was defined, and therefore, where the error originated. -
Define a package that contains all error kinds, and override the default error kind's
KindStringFor
method to return a snake case version of each kind's type.This produces a nicer API for consumers, and allows you to move around error kinds without changing the string emitted by the API.
If using this method in a package, it may be a good idea to prefix with your package name to prevent collisions.
It is recommended to define every error as a public variable, so consumers of your package can check against each error. Avoid defining errors inside of functions.
You can create errors with kinds using the erk
package.
package store
import "github.com/JosiahWitt/erk"
type (
ErkMissingKey struct { erk.DefaultKind }
...
)
var (
ErrMissingReadKey = erk.New(ErkMissingKey{}, "no read key specified for table '{{.tableName}}'")
ErrMissingWriteKey = erk.New(ErkMissingKey{}, "no write key specified for table '{{.tableName}}'")
...
)
func Read(tableName, key string, data interface{}) error {
...
if key == "" {
return erk.WithParam(ErrMissingReadKey, "tableName", tableName)
}
...
}
package main
...
func main() {
err := store.Read("my_table", "", nil)
bytes, _ := json.MarshalIndent(erk.Export(err), "", " ")
fmt.Println(string(bytes))
fmt.Println()
fmt.Println("erk.IsKind(err, store.ErkMissingKey{}): ", erk.IsKind(err, store.ErkMissingKey{}))
fmt.Println("errors.Is(err, store.ErrMissingReadKey): ", errors.Is(err, store.ErrMissingReadKey))
fmt.Println("errors.Is(err, store.ErrMissingWriteKey):", errors.Is(err, store.ErrMissingWriteKey))
}
{
"kind": "github.com/username/repo/store:ErkMissingKey",
"message": "no read key specified for table 'my_table'",
"params": {
"tableName": "my_table"
}
}
erk.IsKind(err, store.ErkMissingKey{}): true
errors.Is(err, store.ErrMissingReadKey): true
errors.Is(err, store.ErrMissingWriteKey): false
You can also wrap a group of errors using the erg
package.
package store
import "github.com/JosiahWitt/erk"
type (
ErkMultiRead struct { erk.DefaultKind }
...
)
var (
ErrUnableToMultiRead = erk.New(ErkMultiRead{}, "could not multi read from '{{.tableName}}'")
...
)
func MultiRead(tableName string, keys []string, data interface{}) error {
...
groupErr := erg.NewAs(ErrUnableToMultiRead)
groupErr = erk.WithParam(groupErr, "tableName", tableName)
for _, key := range keys {
groupErr = erg.Append(groupErr, Read(tableName, key, data))
}
if erg.Any(groupErr) {
return groupErr
}
...
}
package main
...
func main() {
err := store.MultiRead("my_table", []string{"", "my key", ""}, nil)
bytes, _ := json.MarshalIndent(erk.Export(err), "", " ")
fmt.Println(string(bytes))
fmt.Println()
fmt.Println("erk.IsKind(err, store.ErkMultiRead{}): ", erk.IsKind(err, store.ErkMultiRead{}))
fmt.Println("errors.Is(err, store.ErrUnableToMultiRead):", errors.Is(err, store.ErrUnableToMultiRead))
fmt.Println("errors.Is(err, store.ErrMissingReadKey): ", errors.Is(err, store.ErrMissingReadKey))
fmt.Println("errors.Is(err, store.ErrMissingWriteKey): ", errors.Is(err, store.ErrMissingWriteKey))
}
{
"kind": "github.com/username/repo/store:ErkMultiRead",
"message": "could not multi read from 'my_table':\n - no read key specified for table 'my_table'\n - no read key specified for table 'my_table'",
"params": {
"tableName": "my_table"
},
"header": "could not multi read from 'my_table'",
"errors": [
"no read key specified for table 'my_table'",
"no read key specified for table 'my_table'"
]
}
erk.IsKind(err, store.ErkMultiRead{}): true
errors.Is(err, store.ErrUnableToMultiRead): true
errors.Is(err, store.ErrMissingReadKey): false
errors.Is(err, store.ErrMissingWriteKey): false