Go to content
Web development

Using Generics with Go Maps and Slices

Go added generics in version 1.18. The mechanism is the type parameter: a placeholder type that a function declares in square brackets and the compiler fills in at each call site. Before generics, Go had no way to write one function that worked on slices of any element type. A helper that found the index of a string in a slice and a helper that did the same for integers were separate functions with identical logic. Type parameters let you write the logic once and use it with strings, integers, structs, or anything else.

For maps and slices, generics remove the most common reason to copy-paste collection code. You can write a single Map, Filter, or Reduce function, or use the generic helpers the standard library ships in the slices and maps packages. You also need to understand one new concept, the constraint, because it controls which types a generic function accepts. Map keys in particular require the comparable constraint, since Go only allows map keys that can be compared with ==.

By the end, you will know how to write generic collection functions, which constraints to reach for, and where the current limits are.

What a type parameter does for a slice function

The clearest starting point is a function that finds an element in a slice. The Go by Example generics walkthrough shows a SlicesIndex function:

func SlicesIndex[S ~[]E, E comparable](s S, v E) int {
    for i := range s {
        if v == s[i] {
            return i
        }
    }
    return -1
}

The function declares two type parameters in square brackets: S, the slice type, and E, the element type. The constraint ~[]E means "any type whose underlying type is []E", so the function accepts named slice types like type Names []string as well as plain slices. The constraint comparable means E must be a type that can be compared with == and!=, which is exactly what the loop relies on.

When you call SlicesIndex(s, "zoo"), Go’s type inference fills in S and E from the arguments, so you do not need to write the type arguments yourself. You can specify them explicitly when inference cannot work, but most calls stay clean.

You rarely need to write this function, because the standard library ships the same helper as slices.Index. The example is still worth knowing because it shows the two moving parts of any generic collection function: the type parameters and the constraints that limit them.

Since Go 1.20, the language distinguishes strictly comparable types, where comparing values with == is guaranteed not to panic at runtime, from types that merely satisfy comparable without being strictly comparable. The comparable constraint only includes strictly comparable types, so generic code using it avoids the runtime panic you can hit when comparing interface values directly, such as two slices wrapped in interface values. Go constraint reference explains the details.

Map, Filter, and Reduce for slices

Three higher-order functions, meaning functions that take another function as an argument, show up in most collection code: Map, Filter, and Reduce. Map transforms every element, Filter keeps the elements for which a predicate function returns true, and Reduce folds the slice into a single value. Generic versions of all three take the element type as a parameter, so one implementation serves every slice type in your program.

The notes on generic map, filter, and reduce and the Implementing Map, Filter, and Reduce in Go walkthrough show the standard signatures:

func Map[T, R any](slice []T, fn func(T) R) []R
func Filter[T any](slice []T, fn func(T) bool) []T
func Reduce[T, R any](slice []T, initial R, fn func(R, T) R) R

The any constraint accepts every type, so Map can transform a []int into a []string and Reduce can accumulate any result type. Map allocates a result slice of the same length, applies fn to each element, and stores the transformed value at the same index. The new slice Filter builds contains only the elements for which fn returns true. Starting from the initial value, Reduce applies fn to the accumulator and each element in ascending index order, returning the final accumulator.

Map and Reduce carry two type parameters because the input and output types differ. Reduce can reduce a collection of type A into a different type B by adding the second type parameter, without changing the function body or breaking existing callers. The same Reduce function can sum a slice of integers into an int or join a slice of structs into a string.

Generic functions over maps add one rule: the key type parameter must be constrained by comparable, because Go only allows map keys that support ==. The maps package’s generic helpers, such as the functions that return a map’s keys or values, use this pattern.

The Learn Go with Tests chapter on generics recommends using established names for higher-order functions, such as calling a collection-to-collection transform Map rather than Convert, because familiar names reduce cognitive burden and make code searchable.

Constraints: the type set model

A constraint is the set of types a type parameter is allowed to take. Think of it as a type for the type parameter: just as a variable has a type, a type parameter has a constraint, and that constraint is an interface describing a set of types.

Go 1.18 expanded what an interface can express. An interface can name a union of types with the | operator, and it can use a tilde form ~T to mean "any type whose underlying type is T". The Ultimate Go tour shows a constraint built from concrete types:

type addOnly interface {
    string | int | int8 | int16 | int32 | int64 | float64
}

A function constrained by addOnly accepts any of those types and can use the operators they all support, such as +. You can also name map types as terms in a constraint, so the constraint can require a specific map shape like ~map[int]string or accept a union that mixes maps, slices, and arrays.

The restrictions on unions matter when you write your own constraints. As of Go 1.23, a union with more than one term cannot contain the predeclared comparable identifier or any interface with methods, so []byte | comparable and string | error do not compile. Non-basic interfaces, which includes comparable, can only appear as constraints, not as value types, so var x comparable = 123 is illegal. A single type parameter whose constraint starts with * or ( also creates a parsing ambiguity, so you need the full interface{ ... } form or a trailing comma. The Go constraint reference documents these rules.

The Ultimate Go tour also points out a design tension: an interface declared with a set of types cannot be used in a traditional polymorphic function, and the change to interfaces is not orthogonal. The tour’s author says it "doesn’t feel like Go" and that the community will need to figure out over time whether some of this functionality is necessary. For practical purposes, type-set constraints belong in generic functions, and ordinary interfaces remain the tool for polymorphism.

Iterators: map and filter over sequences

Go 1.23 added iterators to the standard library. An iterator is a function that takes a yield callback and calls it once per value. The iter package defines two shapes: iter.Seq, which yields single values, and iter.Seq2, which yields pairs. The pair shape is the natural fit for key-value data such as maps.

A generic Map over an iterator consumes an iter.Seq, applies a function to each value, and calls yield with the result. A tutorial on iterator-based map and filter implements Map and Map2, where Map2 works on iter.Seq2, plus Filter and Filter2, which call yield only when the filter function returns true. Because these functions take and return iterators, you can chain them without building intermediate slices; values flow through the pipeline until something collects them.

The same tutorial notes that these functions work well with the utilities in the slices and maps packages, so you can filter a sequence, transform it, and collect the result with existing standard library helpers.

There is an open proposal for the iter package to add Map, Filter, and Reduce directly. The proposal notes that these functions were dropped from the slices proposal because they would fit better in a more comprehensive streams API, and iter arguably is now that API. It remains a proposal, so you cannot rely on these functions being in the standard library yet.

Performance and when to use generics

The performance question that comes up with generics is whether the abstraction costs anything at runtime. Daniel Lemire tested this in July 2022, shortly after generics shipped. He defined a Number constraint as the union uint | int | float32 | float64, wrote a generic sum function, and applied it to an array of integers. The result compiled to a tight assembly loop, and he concluded that, as far as Go is concerned, this is as efficient as it gets, giving Go generics an A in his assessment.

He also found one place where Go generics are more expressive than Java generics: Java generics only work with classes, not primitive types, while Go lets you constrain a function to a union of numeric types and call it directly with ints or floats.

The practical risk with generics is premature abstraction. The Learn Go with Tests chapter recommends introducing generics during the refactoring stage of test-driven development, after tests have pinned down real behavior, so you can experiment and revert if the abstraction does not pay off. It also advises committing your work before refactoring, so you can undo the generic version cleanly. The chapter calls premature abstraction a common mistake with new language features: reaching for generics on conjecture and guesswork instead of a concrete use case.

The same chapter disagrees with the reflex that generic code is un-Go-like. Go idioms will change because the language changed, and saying "This is not idiomatic" without explaining what to do instead leaves the reader without a next step.

Type inference limits

Type inference handles the common cases, but it has limits. A proposal in the Go issue tracker asks to improve inference for dependent type parameters. The motivating example is a function declared as f[E any, T []E | *E](_ T): the type parameter E is constrained by T, but Go currently fails to infer E when you call f([]bool{}) or f((*bool)(nil)). Under the proposal, the compiler would infer E from the constraint. The change is backward compatible and has no runtime cost, but it would add complexity to the compiler’s type inference system and could slow compilation slightly.

More in Web development Back to top ↑