AlgoMaster Logo

Generic Functions

Medium Priority20 min readUpdated June 6, 2026

Generic functions let you stop writing one helper for []int, another for []string, and a third for []Product. This chapter walks through the small set of generic helpers most Go programs need (filter, map, reduce, min/max, contains, index, zip), looks at how to pick the right constraint for each, and ends with a rule that many developers hit by surprise: methods on generic receivers cannot introduce new type parameters.

Filter Over a Slice

Filtering is the simplest place to start. You have a slice and a predicate that returns true for the elements you want to keep. The non-generic version of this gets rewritten for every element type. The generic version is written once.

Three things stand out. First, the constraint is any. Filter doesn't compare elements or do arithmetic on them, it only passes each one to a predicate, so any is the right choice. Tighter constraints would just lock callers out for no benefit. Second, we pre-size the output slice with make([]T, 0, len(xs)). The filter can keep anywhere from zero to all of the input, but len(xs) is a safe upper bound, so we allocate once instead of growing the slice with each append. Third, we never write the type argument at the call site. Go infers T = Product from the slice we passed in.

Filters compose by chaining: Filter(Filter(products, inStock), cheap) keeps items that pass both predicates. The same Filter works on []int, []string, []Order, anything. The function body never changes because it doesn't depend on the element type for anything except passing it to the predicate.

Map: Transforming Slices

The next building block is Map: take a slice of one type and produce a slice of another by applying a function to each element. Two type parameters this time, one for the input and one for the output.

Map is "convert each element". The output slice has the same length as the input, so we use make([]B, len(xs)) and index into it directly. No append, no capacity guessing. Faster than the filter pattern when you know every input becomes exactly one output.

Here's the typical e-commerce use: converting prices from dollars to cents to avoid float rounding in totals. We define a Cents type so the result of the conversion is self-documenting.

The 0.5 adds half a cent before truncating so prices like 4.99 round to 499 instead of 498. The point of the example is the pipeline: []Product flows in, the conversion function is applied to each element, and []Cents flows out.

The diagram shows the pipeline shape. Map is the generic shell; the per-element function is what defines the transformation. Change the function and the same Map produces a []string, a []float64, or a []Order. The shell never changes.

One detail worth noting: the order of type parameters in the declaration ([A, B any]) matters only for explicit type arguments, which you rarely write. Inference reads the call site (Map(products, fn)) and works out A from products and B from fn's return type. If you ever need to pass type arguments explicitly, the order in the brackets is the order you write them.

Reduce / Fold

Reduce (also called fold) collapses a slice into a single value by applying a function repeatedly. The classic example is summing numbers, but the same shape works for finding a maximum, joining strings, or building a running aggregate. Two type parameters: A for the slice element, B for the accumulator.

Reduce takes a starting value (the seed) and walks left-to-right applying the function. At each step, the accumulator is combined with the next element to produce a new accumulator. After the last element, the final accumulator is returned. Here it's Cents(0) plus 499*2 + 149*5 + 3999*1 = 5742.

The seed type doesn't have to match the element type, which is exactly why we need two type parameters. We're consuming []CartItem but producing a Cents. Without two parameters, Reduce could only collapse []T into a T, which is much more limiting.

The same Reduce works with any accumulator type. The seed could be a map[string]bool (the function mutates and returns the map at each step, useful for counting distinct categories), a strings builder, or a struct holding several running aggregates. The shape stays the same; only the seed type and the combining function change.

The diagram traces the cart-total reduction step by step. The seed is 0. Each cart item contributes Price * Quantity to the accumulator. After the last step, the accumulator is the answer. Reading it left to right makes the "fold" name make sense: each item folds into the running total, and the final value falls out the right side.

Filter and Map can both be expressed as reductions (a filter is a reduce that appends conditionally; a map is a reduce that always appends after transforming). In practice the specialized helpers read better, so most Go codebases keep all three around.

Min, Max, Contains, IndexOf

These four are the small utilities you write over and over before generics. The key question for each is: what does my function actually need from the type? That answers what constraint to use.

Min and Max compare values with <, so they need an ordered type. Go's standard library exposes cmp.Ordered in the cmp package since Go 1.21, which is the right constraint for anything that can be compared with <, <=, >, >=.

Empty-slice handling is a real choice. The version above returns the zero value of T, which is 0.0 for float64, "" for string. That's compact but easy to misread: did the caller pass an empty slice, or was the minimum genuinely zero? In production code you'll often see Min return an error or an (T, bool) for the same reason. We'll cover the error-returning variant later.

Contains and IndexOf ask a different question: are two values equal? They don't need ordering, just equality, so the right constraint is comparable. comparable is wider than cmp.Ordered: it accepts everything Ordered does, plus types like bool, complex, structs with comparable fields, channels, pointers, and so on.

IndexOf returns -1 for not-found, which is the convention strings.Index uses in the standard library. Sticking with that convention is cheaper than inventing a (int, bool) return shape; everyone reading the code already knows what -1 means.

The relationship between the three constraints:

ConstraintAllowsExample typesUse for
anyEverythingint, string, []T, map, funcWhen you only pass values around
comparableAnything with ==int, string, structs of comparables, pointersEquality checks, map keys, set members
cmp.OrderedAnything with <int, float64, string, and named types of thoseSorting, min, max, range comparisons

Picking the tightest constraint that compiles is the rule of thumb. A function that needs < should use cmp.Ordered, not comparable, because that gives the compiler enough information to type-check the body. A function that only needs == should use comparable, not cmp.Ordered, because using Ordered would lock out callers who pass types like bool or struct types built from non-ordered fields.

There's also a question of what Contains does on a slice of floats. == on floats compares bit patterns, which means NaN != NaN. So Contains([]float64{math.NaN()}, math.NaN()) returns false. That's a Go-wide quirk, not a generics-specific one, but it's worth knowing if your slice can hold floats.

Returning Generic Values

Functions can return values whose types depend on the type parameters. The simplest case is a slice (Map returns []B), but you can also return generic struct values, multi-return tuples, or pointers. The pattern shows up most often in constructors.

A Pair[A, B] is the canonical example: a struct holding two values of independent types. We'll use Pair as a return shape because it's a natural fit for functions like NewPair, Zip, and Lookup.

Two things to note. First, the return type Pair[A, B] mentions both type parameters; we're returning a value whose type depends on both inputs. Second, every call site gets a different concrete Pair. The first call returns Pair[Customer, Order], the second returns Pair[string, Customer]. Same function, different types in and out.

Generic returns aren't limited to wrapper structs. A func Repeat[T any](value T, n int) []T that builds a slice of N copies returns []T. The return type carries the type parameter, so Repeat(Product{}, 3) returns []Product, while Repeat(0, 3) returns []int. The generic shell never sees the concrete type, but the signature still tells the caller exactly what they're getting back.

Multi-Parameter Algorithms

Some helpers need to walk two slices in lockstep. Zip is the standard example: given []A and []B, produce a []Pair[A, B] by pairing elements at the same index. The function has three type parameters in spirit (two for the inputs, one composed in the output), but only two need to be declared because the third is built from them.

The function pairs Product with int, producing a []Pair[Product, int]. The decision in the body, n := min(len(as), len(bs)), is the deliberate choice to truncate at the shorter slice rather than panic. Other valid choices are returning an error when lengths differ, or padding the shorter one with zero values. Pick one and document it; the worst option is silently doing one of these without saying which.

Zip opens the door to a ZipWith[A, B, C any](as []A, bs []B, fn func(A, B) C) []C variant that takes a combining function instead of constructing a Pair, so the caller picks the result shape. Three type parameters now: A for the first slice, B for the second, C for the result. Type inference still does the work; the caller writes ZipWith(products, quantities, fn) and Go figures out all three by looking at the slices and the function's return type.

Errors and Generics

Generic functions can return errors just like any other function. The tricky question is what to return alongside the error when the function is supposed to produce a T. Go's convention is to return the zero value of T and a non-nil error, but the zero value of a generic T deserves a closer look.

Here's First, which returns the first element matching a predicate, or an error if no element matches:

The var zero T line creates a value of T's zero value, whatever that is for the concrete type the compiler binds. For int it's 0, for string it's "", for Product it's Product{}, for *Product it's nil. You can't write return nil, ErrNotFound because T might not be a nilable type; var zero T is the generic-safe way.

When the zero value of T is a legal, meaningful value (and it usually is), callers who ignore the error get a T that looks valid but isn't. Consider a Find that returns a Product:

The caller ignored the error and got Product{}, which has ID: 0. That's a perfectly legal Product value as far as the type system is concerned, but it represents "no product". Code that goes on to use p.ID is now writing zero into a database, charging a customer for a non-existent product, or worse. The fix is the rule that holds in non-generic Go too: don't ignore the error.

A pointer-returning variant avoids this by making the empty state explicit:

*T is nilable for any T, so "not found" is nil and "found" is a non-nil pointer. The caller has to dereference, which is the syntactic prompt to check for nil first. Pick whichever shape fits the rest of your codebase, the (T, error) form is more conventional, the *T form is harder to misuse.

A third option is (T, bool), the comma-ok pattern. It works when there's only one possible failure mode and you don't need an error type: func FirstOK[T any](xs []T, pred func(T) bool) (T, bool) returns (value, true) on match and (zero, false) on miss. Same pattern as m[key] on a map or a type assertion. Use it when "not found" isn't really an error, just a normal outcome.

Methods Can't Add Type Parameters

This is the rule that catches everyone. A method on a generic receiver can use the receiver's type parameters, but it cannot introduce new ones of its own. The decision was made deliberately during the generics design and it has stuck.

Here's what works. The receiver type Box[T] has one type parameter, and a method can refer to T in its signature and body:

Get returns T and Set accepts T. The T in the method receiver Box[T] is the same T the methods can use. No new parameters are being introduced; the methods are just borrowing the one the receiver already has.

Now here's what doesn't work. Suppose you want a Map method on Box that transforms the value to a different type. The natural shape is func (b Box[T]) Map[U any](fn func(T) U) Box[U]. That's a compile error:

Compile error:

The error is direct. Methods get to use the receiver's type parameters and that's it. The reason given by the Go team is partly about implementation complexity (method values, interface satisfaction, and method tables get harder when each method can introduce its own quantifiers) and partly about keeping the language predictable: a method's full signature is fixed by the type of the receiver, so the call site doesn't need to track separate quantifiers per method.

The workaround is what generics provided in the first place: a package-level generic function. Move the second type parameter out of the method and into a top-level function that takes the box as an argument.

Same logic, just a free function instead of a method. Both type parameters live in the function's own brackets, so the rule is satisfied. The caller writes MapBox(b, fn) instead of b.Map(fn), which is a tiny syntactic loss in exchange for the feature working.

This is why the slices and maps packages in the standard library are organized as free functions on generic types, not methods. slices.Sort, slices.Index, maps.Keys. The free-function shape sidesteps the methods-can't-add-parameters rule and gives the standard library a uniform style.

The diagram summarizes the rule. The receiver's type parameters flow into the method (the green path). A method that tries to introduce its own type parameter is rejected (the red path). The workaround is to move the extra parameter into a free function (the orange path), which the language fully supports.

Quiz

Generic Functions Quiz

10 quizzes