Writing Generic Mappers in Go

Key Takeaways

  • Use generic function mappers to simplify mapping of one type to another

  • Use function types to extend a functions behavior

When building web applications, it is common to use a layered architecture to separate concerns and improve maintainability. Two common layers are the data access layer and the view layer (API endpoints). The data access layer is responsible for interacting with the database or other persistent data sources while the view layer handles incoming requests and produces responses. To connect these layers, we often need to map types from the data access layer to the view layer.

This mapping can become tedious and error-prone, especially as the application grows. In this blog post, we’ll explore how to use generic functions in Go to write generic mappers that simplify this mapping process and reduce the likelihood of errors.

Implementation

The first step is to define a generic function type that can be used to map one type to another. We’ll call this MapFunc, and it will take two generic type parameters: T and U. The first parameter will be the type of the input value, and the second will be the type of the output value. The any keyword is used to indicate that these parameters can be any Go type, similar to a type parameter in other languages.

type MapFunc[T any, U any] func(T) U
In Go, when declaring a function type, we can also declare methods on the type. This allows us to extend the behavior of the function type. One example from the standard library is the http.HandlerFunc type, which is a function type that can be used to handle HTTP requests. The http package also defines a ServeHTTP method on the http.HandlerFunc type, which allows us to use the function type as a handler for an HTTP server.

Once we’ve defined the generic type, we can implement our helper methods on the type to support various cases. For example, we’ll define a MapEach method that will map a slice of items to a slice of the same length of a different type.

For readability, we can also define a Map method that is an alias for the underlying function. This is optional, but it can be useful for readability.

func (a MapFunc[T, U]) Map(v T) U {
	return a(v)
}

The Next two are optional types that support passing an error parameter. If an error is provided, the function will return the error without attempting to map the value. This can be useful for chaining calls together without declaring a variable for the error.

// MapErr is a convenience method for mapping a value to a different type
// and returning an error if one is provided.
func (a MapFunc[T, U]) MapErr(v T, err error) (U, error) {
	if err != nil {
		var zero U
		return zero, err
	}

	return a(v), nil
}

// MapEachErr is a convenience method for mapping a slice of items to a slice of
// the same length of a different type, and returning an error if one is provided.
func (a MapFunc[T, U]) MapEachErr(v []T, err error) ([]U, error) {
	if err != nil {
		return nil, err
	}

	return a.MapEach(v), nil
}
You can also extend the error methods to accept a format string and arguments if you need to add context to errors before returning them.

Usage

Now that we’ve defined our generic mapper function type, we can use it to define a mapper function for a specific type. For example, we can define a mapper function that converts a User type to a UserResponse type.

func UserToUserResponse(user User) UserResponse { // ~(-1) Untypes Mapper
	return UserResponse{
		ID:        user.ID,
		FirstName: user.FirstName,
		LastName:  user.LastName,
		Email:     user.Email,
	}
}

type UserHandlers struct {
	mapper MapFunc[User, UserResponse]
}

func NewUserHandlers(mapper MapFunc[User, UserResponse]) *UserHandlers {
	return &UserHandlers{
		mapper: automapper.MapFunc(UserToUserResponse), // ~(-1) Cast Mapper
	}
}

func GetAllUsers() ([]UserResponse, error) {
	users, err := db.GetAllUsers()
	if err != nil {
		return nil, err
	}

	return mapper.MapEach(users), nil
}

func GetUser() (UserResponse, error) {
	user, err := db.GetUser(id)
	if err != nil {
		return UserResponse{}, err
	}

	return mapper.Map(user), nil
}

Conclusion

We explored how generic functions in Go can be used to write mappers that simplify the process of mapping one type to another. We also learned how to extend the behavior of a generic function type by defining methods on the type.

By utilizing this pattern, you can create more consistent and maintainable code that aligns with your specific domain needs. Consider incorporating these techniques into your next project to reduce code complexity and improve readability.

Full Example

package automapper

type MapFunc[T any, U any] func(T) U

// Map is a aliased call to the underlying function, it is optional
// to define this method, but it can be useful for readability.
func (a MapFunc[T, U]) Map(v T) U {
	return a(v)
}

// MapEach is a convenience method for mapping a slice of items to a slice of
// the same length of a different type.
func (a MapFunc[T, U]) MapEach(v []T) []U {
	result := make([]U, len(v))
	for i, item := range v {
		result[i] = a(item)
	}
	return result
}

// MapErr is a convenience method for mapping a value to a different type
// and returning an error if one is provided.
func (a MapFunc[T, U]) MapErr(v T, err error) (U, error) {
	if err != nil {
		var zero U
		return zero, err
	}

	return a(v), nil
}

// MapEachErr is a convenience method for mapping a slice of items to a slice of
// the same length of a different type, and returning an error if one is provided.
func (a MapFunc[T, U]) MapEachErr(v []T, err error) ([]U, error) {
	if err != nil {
		return nil, err
	}

	return a.MapEach(v), nil
}