Error Handling Patterns in Go: From Basic Errors to Chainable Error Types

1. Evolution of Error Handling ------- For over a decade before Go 1.13, the standard library provided limited support for error handling, offering only errors.New and fmt.Errorf functions for creating error instances. However, Go defined error as a built-in interface type, allowing developers to implement custom error types with arbitrary content. Prior to Go 1.13, several open-source projects attempted to extend the standard library's error capabilities to meet real-world requirements. The pkg/errors package, for instance, saw widespread adoption in large-scale projects like Kubernetes. Go 1.13 introduced new error types while maintaining backward compatibility. These new error types can preserve original error information when passed between functions, creating what are known as chainable errors. 2. The Error Interface ----------- Error is a built-in interface type, meaning it can be used directly without importing any packages, much like int or string. The error interface declares only one method, Error(). Any type implementing this method can be used as an error. An error instance represents an exceptional state, with the Error() method describing that state. A nil error indicates no exception. The errorString type in the standard library's errors package is one implementation of the error interface: errorString is a private type within the errors package, not directly accessible from outside. It can only be instantiated through the public interfaces provided. 3. Creating Errors ---------- The standard library provides two methods for creating errors: errors.New() fmt.Errorf() ### 3.1 errors.New() The implementation of errors.New is straightforward - it creates an errorString instance and returns it: ### 3.2 fmt.Errorf() While errors.New accepts a simple string parameter to construct an error, real-world scenarios often require formatted strings. fmt.Errorf addresses this need by leveraging fmt.Sprintf's formatting capabilities. fmt.Errorf is essentially a wrapper around errors.New with added formatting functionality. ### 3.3 Performance Comparison fmt.Errorf is suitable for scenarios requiring string formatting. If simple string creation suffices, errors.New is more performant. Consider these benchmark examples: func BenchmarkFmtCreation(b *testing.B) { for i := 0; i < b.N; i++ { fmt.Errorf("sample error") } } And: func BenchmarkDirectCreation(b *testing.B) { for i := 0; i < b.N; i++ { errors.New("sample error") } } Analysis with benchstat shows that fmt.Errorf incurs performance overhead due to character traversal during string formatting. Now consider a case requiring string formatting: func BenchmarkFormattedError(b *testing.B) { for i := 0; i < b.N; i++ { fmt.Errorf("processing failed: %s", "validation") } } Versus: func BenchmarkSprintfPlusNew(b *testing.B) { for i := 0; i < b.N; i++ { errors.New(fmt.Sprintf("processing failed: %s", "validation")) } } Using fmt.Sprintf with errors.New for formatting is relatively fast. > The above represents validation in a specific environment and may not be universally applicable. 4. Custom Error Types ----------- Any type that implements the error interface qualifies as an error. For example, the PathError type in the standard library's os package is one such implementation: 5. Error Handling ------- Error handling encompasses checking errors and propagating them through the application. ### 5.1 Checking Errors The most common way to check for errors is comparison with nil: Sometimes errors are compared against predefined error values: Since any type implementing the error interface can be treated as an error, type assertions are frequently used for error checking: ### 5.2 Propagating Errors When a function receives an error, it often needs to add contextual information before passing it up the call stack. The most common approach is to use fmt.Errorf to attach context: func createProcessingErr(originalErr error) error { return fmt.Errorf("data processing error: %v", originalErr) } This approach has a drawback: the original error and contextual information become merged into a new error, making type assertions impossible at higher levels. To solve the problem of losing underlying error details, consider the PathError pattern: type ProcessingError struct { Operation string Input string Original error } When making assertions, first check the outer error type, then examine the underlying error: if procErr, ok := err.(*ProcessingError); ok && procErr.Original == os.ErrPermission { fmt.Println("permission denied during processing") } 6. Chainable Errors ---------- Before Go 1.13, when using fmt.Errorf to propagate captured errors and add context, the original error information would blend with the new context. This made it impossible to retrieve the original error. Go 1.13 introduced chainable errors as a solution. When errors are passed between functions, contextual information connects them in a linked-list structure. The wrapError structure resembles PathError: PathError stores contextual information through Op and Path fields while preserving the underlying error in Err. Similarly, wrapError stores both context and original error through its msg and err fields. In Go 1.13, fmt.Errorf was updated to use wrapError instead of errorString. Additionally, the Unwrap() interface was implemented to return the original error. 7. Enhanced fmt.Errorf ------------ In Go 1.13, fmt.Errorf introduced a new format verb `%w` (wrap) for generating wrapError instances while maintaining compatibility with existing format verbs. func Errorf(format string, a ...interface{}) error { p := newPrinter() p.wrapErrs = true p.doPrintf(format, a) s := string(p.buf) var err error // If no %w verb is used, create a basic error if p.wrappedErr == nil { err = errors.New(s) } else { // If %w is used, create a wrapError err = &wrapError{s, p.wrappedErr} } p.free() return err } In print.doPrintf, the %w verb is parsed. Since error is an interface, it's categorized under methods. The implementation locates the `%` position and processes the input parameters. For interface types, it follows specific logic. When handling the %w verb, the system processes it accordingly. Generally, fmt.Errorf("xx %v", err) generates an errorString because %v is used. In contrast, fmt.Errorf("xx %w", err) produces a wrapError due to the %w verb. ### 7.1 fmt.Errorf Accepts Only One %w Based on the implementation analysis, only one %w verb can be accepted at a time because wrapError can store only one original error. Attempting to use multiple %w verbs results in a compilation error. > In version 1.20, the wrapErrors type was introduced, expanding the error from a single member to an array. > During Errorf parsing, if no wrapError exists, an errorString is created. If one wrapError exists, a wrapError is created. If multiple wrapErrors exist, wrapErrors is used instead. ### 7.2 %w Only Matches Error Parameters Since the %w verb is categorized under interfaces, non-interface parameters will cause compilation failure. Even for regular interfaces: type Data interface { } type record struct{} func TestFmt(t *testing.T) { item := record{} wrapped := fmt.Errorf("data %w %w", item, item) fmt.Println(wrapped) fmt.Println(errors.Unwrap(wrapped)) } This will fail to compile because the interface doesn't implement the Error() method. Note that while wrapError implements the Unwrap interface, the error interface itself only defines the Error method. Therefore, errors created with fmt.Errorf cannot directly call their Unwrap method; instead, the Unwrap method from the errors package must be used. 8. errors.Unwrap ---------------- The Unwrap method retrieves the original error, while fmt.Errorf is used to wrap the original error. If the error doesn't implement the Unwrap function, it's not a wrapError, and nil is returned. Otherwise, the Unwrap function is called and its result returned. For custom error types, implementing the Unwrap function in addition to Error transforms them into chainable errors. For example, os.PathError: > This relates to Go's duck typing approach. While no explicit Unwrap interface exists in the codebase, the system checks for implementations through type assertions: > > u, ok := err.(interface { > Unwrap() error > }) > > This determines if a struct implements the interface and allows calling the Unwrap method. 9. errors.Is ------------ errors.Is checks whether a specific error chain contains a target error value. func Is(err, target error) bool { // If target is nil, check if err is also nil if target == nil { return err == target } // Check if target type is comparable isComparable := reflectlite.TypeOf(target).Comparable() for { if isComparable && err == target { return true } // If err implements Is interface, call its Is method if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) { return true } // Otherwise, continuously unwrap and compare if err = Unwrap(err); err == nil { return false } } } errors.Is recursively unwraps err and compares it with target, returning true if a match is found. For custom error types, if they implement their own Is method, that method is called during comparison. 10. errors.As ------------- When performing type assertions on errors, type assertions become ineffective with chainable errors. Unless you manually unwrap and attempt type assertions layer by layer. In Go 1.13, errors.As searches an error chain for a specified type. If found, it converts the error to that type. func As(err error, target interface{}) bool { if target == nil { panic("errors: target cannot be nil") } // Use reflection to get type information val := reflectlite.ValueOf(target) typ := val.Type() // Check for invalid types if typ.Kind() != reflectlite.Ptr || val.IsNil() { panic("errors: target must be a non-nil pointer") } // Verify it's an error if e := typ.Elem(); e.Kind() != reflectlite.Interface && !e.Implements(errorType) { panic("errors: *target must be interface or implement error") } targetType := typ.Elem() // Loop through unwrapping and attempting type assertions for err != nil { // If err matches targetType, assign it to target // This requires target to be passed by reference if reflectlite.TypeOf(err).AssignableTo(targetType) { val.Elem().Set(reflectlite.ValueOf(err)) return true } // If err implements As, try using its As method if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) { return true } // Continue unwrapping until original error is empty err = Unwrap(err) } return false } 11. Upgrading from Regular Errors to Chainable Errors ------------------------- Due to Go's strict backward compatibility rules, programs developed with older Go versions can be upgraded without any modifications while maintaining expected behavior. To upgrade from regular errors to wrapError, the following adaptations are necessary: - Change the format verb in fmt.Errorf from %v to %w when creating errors - Replace equality (==) comparisons with errors.Is - Use errors.As instead of type assertions - Implement the Unwrap method for custom types - Optionally implement As and Is methods for custom types 12. Error Handling Best Practices ------ An error is simply a built-in interface—any type implementing this interface qualifies as an error. Create errors using errors.New and fmt.Errorf, with the distinction being whether string formatting is required. The Go SDK implements internal errorString and wrapError types. The primary improvement in Go 1.13 addresses the issue of losing original error information during propagation, achieved by extending fmt.Errorf to support chainable errors and providing errors.Unwrap to retrieve the original error. errors.Is recursively unwraps errors to check for specific values, while errors.As recursively unwraps errors to check for specific types, assigning the error to the target variable if found.

Tags: Go Error Handling programming software development chainable errors

Posted on Fri, 08 May 2026 13:12:19 +0000 by Paulkirkewalker