Are You Checking Error Types Correctly in Go?

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:

  1. Create separate error objects for each entity (i.e., ArticleNotFound, UserNotFound).
  2. Add a type parameter in the error and set that in the persistence layer, passing context to the error.

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:

  • Credentials are invalid (CredentialError)
  • Valid credentials, but an active session exists (ActiveSessionError)

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.

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.

Article content

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?

  • errors.As: The errors.As function checks whether the error (or any wrapped error) is of a particular type, even if it’s wrapped with additional context. We use it to match both the CredentialError and ActiveSessionErrortypes, ensuring the correct status codes are returned.

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!

Alexander Baronets

Senior Software Engineer

4mo

for 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()) }

To view or add a comment, sign in

More articles by Archit Agarwal

Insights from the community

Others also viewed

Explore topics