Are You Checking Error Types Correctly in Go?
As a developer transitioning from languages like Java, .NET, or TypeScript, you’ve probably missed the convenience of try-catch blocks while navigating Go’s unique approach to error handling. And let’s be honest—debugging wrapped errors in Go can be a real headache. Why does your code fail to recognize an error as the expected type, even when it’s just a wrapped version of it?
In this article, we’ll uncover the common pitfalls of Golang error type checking, explain why these issues occur, and provide practical solutions to avoid them.
Before diving into the solutions, let’s first understand error wrapping. If you're already familiar with this concept, feel free to skip ahead to the next section.
What is Error Wrapping in Go, and Why Should You Care?
Let’s consider a scenario where you’re implementing CRUD operations for User and Article entities. For simplicity, let’s focus on the Get method. These methods typically require an ID as their primary input.
In the service layer, you might encounter an error from the persistence layer, such as NotFound, as shown in the following examples:
func (p ArticlePersistence) Get(id int) (*entities.Article, error) {
if id == 1 {
return &entities.Article{
Id: 1,
Title: "Article 1",
Description: "Article 1 description",
CreatedAt: time.Now().Add(-10 * time.Hour),
CreatedBy: 1,
}, nil
}
return nil, apperrors.NotFound{Id: id} -- 1️⃣
}
func (p UserPersistence) Get(id int) (*entities.User, error) {
if id == 1 {
return &entities.User{
Id: 1,
Name: "user-1",
CreatedDate: time.Now().Add(-10 * time.Hour),
}, nil
}
return nil, apperrors.NotFound{Id: id} -- 2️⃣
}
In both cases (1️⃣ & 2️⃣), the NotFound error is returned from the persistence layer when the id doesn’t match any existing record. Now, in the service layer, you might want to add more context to the error before passing it up to the router layer (where we have the traditional Golang error checking or error handling in Golang) so that when the error is logged, you have a clearer understanding of which entity wasn’t found.
You could take one of two approaches:
However, both of these methods become cumbersome if you have 50 entities, and let’s be honest—nobody wants to rewrite a ton of code or manually review so many changes. So, what’s the solution? 🧐
Elegant Solution: Error Wrapping in Golang
Golang has an elegant solution to this problem: wrapping the error in Go, which is like adding a layer of information on top of the existing error. This is done using the %w format specifier.
Instead of using either of the two approaches mentioned above, we can leverage Golang’s built-in error-wrapping mechanism. Here’s how it works in practice:
func (svc ArticleService) Get(id int) (*dto.Article, error) {
userInfo, err := svc.articlePersistenceObj.Get(id)
if err != nil {
return nil, fmt.Errorf("%w, entity that is not found is article", err) -- 1️⃣
}
return (&dto.Article{}).Init(userInfo), nil
}
In the code above (1️⃣), we receive an error from the persistence layer and wrap that error with additional context about what entity is not found, using the %w format specifier.
You can find the complete code for this article, including the full implementation and examples, in the GitHub repository here: GitHub Repository.
Now, comes the part that we are waiting for.
The Hidden Costs of Mischecking Error Types in Go
Imagine you're building a login functionality using Go and Gin (github.com/gin-gonic/gin). For this example, let’s assume we’re creating an API server that doesn’t allow multiple active sessions for any user. The two primary errors we need to handle are:
For clarity, the CredentialError comes from the persistence layer, whereas the ActiveSessionError is a business logic issue, so it's returned from the service layer.
Let’s take a look at the initial code:
func (svc LoginPersistence) AuthenticateUser(authReq *dto.AuthRequest) (*entities.User, error) {
if authReq.UserName == "user2" && authReq.Password == "password2" {
// this user has an active session
userInfo := new(entities.User)
return userInfo, nil
} else if authReq.UserName == "user3" && authReq.Password == "password3" {
// this user does not has any active session
userInfo := new(entities.User)
return userInfo, nil
}
return nil, apperrors.CredentialError{} --- 1️⃣
}
func (svc LoginService) AuthenticateUser(authReq *dto.AuthRequest) (*dto.AuthResponse, error) {
userInfo, err := svc.loginPersistenceObj.AuthenticateUser(authReq)
if err != nil {
return nil, err --- 2️⃣
}
activeSessions := userInfo.Sessions.Filter(func(val *entities.Session) bool {
return val.EndTime.IsZero() --- 3️⃣
})
if len(activeSessions) > 0 {
return nil, apperrors.ActiveSessionError{} --- 4️⃣
}
return (&dto.AuthResponse{}).Init(userInfo), nil
}
func (r LoginRouter) Authenticate(c *gin.Context) {
var authReq dto.AuthRequest
if err := c.ShouldBindJSON(&authReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userInfo, err := r.loginServiceObj.AuthenticateUser(&authReq)
if err != nil {
log.Println(err) --- 5️⃣
switch err := err.(type) { --- 6️⃣
case apperrors.CredentialError:
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
case apperrors.ActiveSessionError:
c.JSON(http.StatusPreconditionFailed, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, userInfo)
}
What’s going on here?
1️⃣ - CredentialError: If invalid credentials are provided, the persistence layer returns CredentialError.
Recommended by LinkedIn
2️⃣ - Service Layer: We return the error from the persistence layer directly to the router layer.
3️⃣ - Active Session Check: If no errors are returned from the persistence, we check if there are active sessions.
4️⃣ - ActiveSessionError: If the user has an active session, we return the ActiveSessionError.
5️⃣ - Router: We log the error and match it using a switch block to return the correct HTTP status code.
The Hidden Issue: Wrapped Errors and Mischecked Error Types
Let’s say we need to add more context to the error when credentials are invalid. For example, we might want to log failed login attempts for specific users, triggering alerts if one user has too many failed login attempts.
To do this, we wrap the error in the service layer with additional context:
func (svc LoginService) AuthenticateUser(authReq *dto.AuthRequest) (*dto.AuthResponse, error) {
userInfo, err := svc.loginPersistenceObj.AuthenticateUser(authReq)
if err != nil {
return nil, fmt.Errorf("request (%+v), %w", authReq, err) --- 1️⃣
}
activeSessions := userInfo.Sessions.Filter(func(val *entities.Session) bool {
return val.EndTime.IsZero()
})
if len(activeSessions) > 0 {
return nil, apperrors.ActiveSessionError{}
}
return (&dto.AuthResponse{}).Init(userInfo), nil
}
At this point, we expect a StatusUnauthorized response when the credentials are incorrect. But instead, we're getting a StatusInternalServerError. Why?
When we wrap the CredentialError, the type of the error changes. The error is no longer just a CredentialError but a wrapped version of it. As a result, the switch block in the router never matches the expected error type, leading to the wrong response.
The Solution: Using errors.As to Check Wrapped Errors
Golang provides a simple solution to handle such cases: the errors.As (https://pkg.go.dev/errors#example-As) function. This function allows us to check if an error is of a specific type, even if it’s wrapped.
Let’s modify the router to handle wrapped errors:
func (r LoginRouter) AuthenticateV2(c *gin.Context) {
var authReq dto.AuthRequest
if err := c.ShouldBindJSON(&authReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userInfo, err := r.loginServiceObj.AuthenticateUser(&authReq)
if err != nil {
log.Println(err)
var credErr apperrors.CredentialError
var sessionErr apperrors.ActiveSessionError
if errors.As(err, &apperrors.CredentialError{}) { --- 1️⃣
c.JSON(http.StatusUnauthorized, gin.H{"error": credErr.Error()})
} else if errors.As(err, &apperrors.ActiveSessionError{}) { --- 2️⃣
c.JSON(http.StatusPreconditionFailed, gin.H{"error": sessionErr.Error()})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, userInfo)
}
What’s happening here?
By using errors.As, we ensure that the router correctly handles both wrapped and non-wrapped errors.
Conclusion
Mischecking error types can lead to unexpected behaviour and make debugging a frustrating experience. By using Go's error-wrapping feature along with errors.As, you can handle errors with context, avoid common pitfalls, and ensure your code remains reliable and easy to maintain.
Ready to Tackle Error Handling Like a Pro?
Now that you understand the importance of correctly checking error types in Go and how to leverage error wrapping effectively, it’s time to take your skills to the next level!
Dive into the full code examples and see how you can implement error handling in your projects. Check out the complete code and more resources on my GitHub repository.
Don't let error handling slow you down—start building cleaner, more robust Go applications today!
Senior Software Engineer
4mofor better readability, last if would be better to change to switch, like this switch { case errors.As(err, &apperrors.CredentialError{}): respondWithError(c, http.StatusUnauthorized, "Invalid credentials") case errors.As(err, &apperrors.ActiveSessionError{}): respondWithError(c, http.StatusPreconditionFailed, "Active session exists") default: respondWithError(c, http.StatusInternalServerError, err.Error()) }