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
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
}
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
}