The missing functional tools
You come from Python or JavaScript where map, filter, and reduce are built-in. You write a Go loop and it feels verbose. You want to write a generic utility to bring that functional style back. Go 1.18 added generics, so you can define functions that work on any type. You can write your own Map, Filter, and Reduce.
Before you paste a library into your project, understand how these functions interact with Go's type system and error handling. Go has a different philosophy than functional languages. The standard library does not include these helpers. You can write them, but you need to know when they help and when they fight the language.
Higher-order functions with type parameters
A higher-order function takes another function as an argument or returns a function. Map takes a slice and a transformation function. It applies the function to every element and returns a new slice. Filter takes a predicate function that returns a boolean. It keeps elements where the predicate is true. Reduce takes a combiner function. It collapses the slice into a single value by accumulating results.
Generics let you write these functions once and use them with []int, []string, or []User. You define type parameters like T and U in square brackets. T is the input type. U is the output type. The constraint any means the type can be anything.
Minimal examples
Here's the generic Map function. It pre-allocates the result slice because the output length matches the input length.
// Map applies fn to each element of slice and returns a new slice of results.
func Map[T, U any](slice []T, fn func(T) U) []U {
// Pre-allocate result slice to avoid reallocations during the loop.
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
Filter works differently. You don't know how many elements will pass the test, so you start with a nil slice and use append. The append builtin grows the slice automatically as needed.
// Filter returns a slice containing only elements where fn returns true.
func Filter[T any](slice []T, fn func(T) bool) []T {
// Start with nil slice; append will allocate as needed.
var result []T
for _, v := range slice {
if fn(v) {
result = append(result, v)
}
}
return result
}
Reduce needs an initial value. This version uses the first element of the slice as the accumulator. It panics if the slice is empty because there is no safe default value for an arbitrary type T.
// Reduce combines all elements into a single value using fn.
// It panics if the slice is empty because there is no initial value.
func Reduce[T any](slice []T, fn func(T, T) T) T {
if len(slice) == 0 {
panic("Reduce of empty slice")
}
// Use the first element as the initial accumulator.
acc := slice[0]
for i := 1; i < len(slice); i++ {
acc = fn(acc, slice[i])
}
return acc
}
Generics give you the types. You still write the loops.
What happens at compile and runtime
When you call Map(nums, func(x int) int { return x * 2 }), the compiler infers the types. It sees nums is []int and the function takes int and returns int. It sets T to int and U to int.
Go uses monomorphization for generics. The compiler generates a specialized copy of the function for each type combination you use. If you call Map with []int and []string, the compiler produces two different machine code functions. One works with integers, one with strings. There is no runtime type check overhead. The generated code is as fast as a hand-written loop for that specific type.
The binary size grows slightly because of the duplicated code. For small programs, this is negligible. For massive projects with thousands of generic instantiations, the binary can get larger. This is a trade-off for type safety and performance.
Public names start with a capital letter. Name your function Map, not map, if you want to export it from a package. The convention is clear: capital means exported. If you define these in a local file, lowercase is fine.
Realistic usage with structs
Here's how you chain these in a realistic scenario. You have a list of users, you want the names of active users, and you want to join them into a single string.
type User struct {
Name string
Active bool
}
// GetActiveUserNames filters active users and extracts their names.
func GetActiveUserNames(users []User) []string {
// Filter first to reduce the size of the slice before mapping.
active := Filter(users, func(u User) bool {
return u.Active
})
// Map the filtered slice to just the names.
return Map(active, func(u User) string {
return u.Name
})
}
Filtering before mapping reduces the number of calls to the map function. If only 10% of users are active, you avoid transforming 90% of the data. Order matters for performance when chaining operations.
Context is plumbing. If your function processes data from a network call or database, it needs a context.Context parameter. The simple generic signature above doesn't include context. You would need to pass context through the callback or change the design. Functions that take a context should respect cancellation and deadlines.
Pitfalls and compiler errors
Generic helpers hide complexity. They also hide errors. Go prefers explicit error handling. If your transformation function returns an error, Map cannot propagate it easily.
Suppose you want to parse a slice of strings into integers. The parsing function returns (int, error). You cannot pass this to Map because the signature expects func(T) U, not func(T) (U, error). The compiler rejects this with cannot use func(string) (int, error) as func(string) int in argument.
You can write a MapWithErrors function, but it forces you to handle errors inside the loop. This breaks the clean functional chain.
// MapWithErrors shows the friction of error handling in functional style.
// This signature forces the caller to handle errors from every element.
func MapWithErrors[T, U any](slice []T, fn func(T) (U, error)) ([]U, error) {
result := make([]U, len(slice))
for i, v := range slice {
val, err := fn(v)
if err != nil {
// Return immediately on the first error.
return nil, err
}
result[i] = val
}
return result, nil
}
This is verbose. A plain loop with an early return is often clearer. The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Wrapping errors in a functional chain makes stack traces harder to read.
Loop variable capture is another trap. In older Go versions, capturing a loop variable in a closure created a shared variable. Go 1.22 changed this. If you capture a loop variable in a function literal, the compiler now rejects the program with loop variable i captured by func literal. You must create a local copy or use the range value directly.
// This pattern is safe in Go 1.22+.
// The range value v is a new variable for each iteration.
for _, v := range slice {
go func() {
// v is captured safely.
process(v)
}()
}
The compiler catches type mismatches. It won't save you from a callback that panics. If fn panics, the whole operation stops.
When to use generics versus loops
Functional helpers are tools, not religion. Clarity is the goal.
Use a generic Map function when you need to transform every element of a slice and the transformation logic is reusable across different parts of your codebase. Use a generic Filter function when you are extracting a subset of data based on a predicate and the filtering criteria change frequently. Use a generic Reduce function when you need to aggregate a slice into a single value and the aggregation logic is complex enough to warrant extraction. Use a plain for loop when the transformation is simple, one-off, or involves error handling that breaks the functional chain. Use a range over a channel when the data stream is infinite or produced by a concurrent worker, rather than a finite slice in memory.
Functional style is a choice. Clarity is the goal.