Search and navigate

Open full search and filters →

Generic Functions in Go: Writing Reusable Code

Write golang generic functions the right way: Map, Filter, Reduce, and a drop-in utilities package. Every example tested on Go 1.24 with real output.

Standard-library lessonGo requirement: Go 1.21+How tutorials are checked
Generics · Lesson 3Saved in this browser. No account required.

Generic Functions in Go: Writing Reusable Code

This tutorial shows you how to write generic functions in Go that carry their weight: a real functional toolkit (Map, Filter, Reduce, GroupBy, Unique) built and tested, the constraint you pick for each and why, and the stdlib functions that mean you should not write half of them. Every example was run on Go 1.24.7.

Works with Go 1.21+ (generics landed in 1.18; cmp, slices, and maps arrived in 1.21). If generics are new to you, read the Go generics overview first; this article is the function-writing half of that story. Total beginners should start with the complete Go tutorial.

A generic function is one type parameter plus one constraint

Before generics, a function that summed a slice of int64 and a function that summed a slice of float64 were two functions with identical bodies. A type parameter collapses them into one. You declare it in square brackets after the function name, give it a constraint, and use it like any other type:

package main

import "fmt"

// SumNumbers adds every element of a slice. The type parameter T is
// constrained to int64 | float64, so the + operator is valid for it.
func SumNumbers[T int64 | float64](values []T) T {
	var total T
	for _, v := range values {
		total += v
	}
	return total
}

func main() {
	latencies := []int64{230, 120, 890, 45}
	prices := []float64{9.99, 19.95, 4.50}

	fmt.Println("total latency:", SumNumbers(latencies))
	fmt.Println("total price:  ", SumNumbers(prices))
}
total latency: 1285
total price:   34.44

Two things are doing the work. [T int64 | float64] declares a type parameter T whose constraint is a union: T can be int64 or float64 and nothing else. The constraint is a promise about what operations are legal inside the function. Because both types in the union support +, total += v compiles. Try to add a type that does not support + and the compiler rejects it at the call site, not at runtime.

Notice you never wrote SumNumbers[int64](latencies). Go infers the type argument from the slice you passed. Type inference is why generic call sites in Go stay clean; you spell out the type parameter only when inference cannot figure it out.

The spec’s section on type parameters is the authoritative reference, and the deeper mechanics live in type parameters explained. Here we care about using them well.

Map: two type parameters and a function passed as a callback

The union constraint above is the narrow case. Most reusable functions want to accept any type at all, transform it, and hand back a different type. That needs two type parameters and a function argument. Map is the canonical example:

package main

import (
	"fmt"
	"strconv"
	"strings"
)

// Map applies transform to every element of src and returns a new slice.
// T is the input element type, U is the output element type; both are any.
func Map[T, U any](src []T, transform func(T) U) []U {
	out := make([]U, len(src))
	for i, v := range src {
		out[i] = transform(v)
	}
	return out
}

func main() {
	ids := []int{101, 102, 103}

	labels := Map(ids, func(id int) string {
		return "user-" + strconv.Itoa(id)
	})
	fmt.Println(labels)

	names := []string{"ada", "grace", "katherine"}
	upper := Map(names, strings.ToUpper)
	fmt.Println(upper)

	lengths := Map(names, func(s string) int { return len(s) })
	fmt.Println(lengths)
}
[user-101 user-102 user-103]
[ADA GRACE KATHERINE]
[3 5 9]

Map[T, U any] reads: for any input type T and any output type U, take a []T and a function that turns one T into one U, and return a []U. The constraint on both is any, which is an alias for interface{}: no operations required, because Map never inspects the values, it only passes them to transform.

Three calls, three different U inferred from the callback’s return type: string, then string again (note you can pass strings.ToUpper directly, no wrapper), then int. The type parameter for the output is inferred from what the function returns. This is the pattern behind every functional helper: the caller supplies behavior as a function value, the generic machinery supplies the plumbing.

A Go Map generic function transforms an input slice through a callback into an output slice with a different element type.

One habit worth keeping: preallocate the output with make([]U, len(src)). You know the exact length up front, so there is no reason to let append reallocate as it grows. That is the same lesson from slices and maps, just applied inside a generic function.

Choose the constraint per function: any, comparable, or cmp.Ordered

The single most common mistake in generic code is the wrong constraint: too loose and the body will not compile, too tight and callers cannot use it. There are three constraints you will reach for ninety percent of the time. Pick the loosest one that still lets your body compile.

package main

import (
	"cmp"
	"fmt"
)

// any: no operations required on T beyond passing it around.
func First[T any](s []T) (T, bool) {
	var zero T
	if len(s) == 0 {
		return zero, false
	}
	return s[0], true
}

// comparable: T must support == and !=.
func Contains[T comparable](s []T, target T) bool {
	for _, v := range s {
		if v == target {
			return true
		}
	}
	return false
}

// cmp.Ordered: T must support < <= > >=.
func Max[T cmp.Ordered](s []T) T {
	best := s[0]
	for _, v := range s[1:] {
		if v > best {
			best = v
		}
	}
	return best
}

func main() {
	fmt.Println(First([]string{"a", "b"}))
	fmt.Println(Contains([]int{4, 8, 15, 16}, 15))
	fmt.Println(Max([]float64{2.7, 9.1, 3.3}))
}
a true
b

Well, almost. That output is wrong, and it is the kind of thing you only learn by running the code, so here is the real run:

a true
true
9.1

The rule the three functions demonstrate:

A comparison of Go generic constraints: any passes values through, comparable enables equality, and cmp.Ordered enables ordering.

  • First touches nothing, so any is correct. Constraining it to comparable would reject valid callers (a slice of slices, say) for no reason.
  • Contains uses ==, which requires comparable. That constraint covers all basic types, pointers, channels, and structs whose fields are all comparable. It excludes slices, maps, and functions, because those cannot be compared with ==.
  • Max uses >, which comparable does not grant. cmp.Ordered (from the cmp package, Go 1.21+) is the constraint for the ordered types: integers, floats, and strings.

Get this wrong in the loose direction and the compiler is blunt about it. Write Contains with any instead of comparable:

func Contains[T any](s []T, target T) bool {
	for _, v := range s {
		if v == target { // won't compile
			return true
		}
	}
	return false
}
./main.go:5:6: invalid operation: v == target (incomparable types in type set)

The fix is to tighten the constraint to comparable, which is exactly what promises == is available. Read the constraint as the set of operations you are allowed to use on T. If the body needs >, the constraint must guarantee >.

The slice utilities worth having: Filter, Reduce, IndexOf, Min

With constraint choice settled, the functional toolkit writes itself. These are the ones you will actually paste into a project. Each shows its constraint doing real work:

package main

import (
	"cmp"
	"fmt"
)

// Filter returns a new slice holding only the elements that satisfy keep.
func Filter[T any](src []T, keep func(T) bool) []T {
	out := make([]T, 0, len(src))
	for _, v := range src {
		if keep(v) {
			out = append(out, v)
		}
	}
	return out
}

// Reduce folds src into a single accumulator value.
func Reduce[T, A any](src []T, initial A, combine func(A, T) A) A {
	acc := initial
	for _, v := range src {
		acc = combine(acc, v)
	}
	return acc
}

// IndexOf returns the position of target, or -1 if absent.
func IndexOf[T comparable](src []T, target T) int {
	for i, v := range src {
		if v == target {
			return i
		}
	}
	return -1
}

// Min returns the smallest element. Panics on an empty slice, by design.
func Min[T cmp.Ordered](src []T) T {
	best := src[0]
	for _, v := range src[1:] {
		if v < best {
			best = v
		}
	}
	return best
}

func main() {
	statusCodes := []int{200, 404, 200, 500, 301, 503}

	errors := Filter(statusCodes, func(c int) bool { return c >= 500 })
	fmt.Println("server errors:", errors)

	sum := Reduce(statusCodes, 0, func(acc, c int) int { return acc + c })
	fmt.Println("sum:          ", sum)

	fmt.Println("index of 500: ", IndexOf(statusCodes, 500))
	fmt.Println("min code:     ", Min(statusCodes))
}
server errors: [500 503]
sum:           2108
index of 500:  3
min code:      200

Reduce earns its two type parameters. T is the element type, A is the accumulator type, and they are often different: reduce a []Order into an int total, or a []string into a single joined string. The initial argument seeds the fold and pins down A for inference.

About that Min panic on an empty slice: it is a deliberate choice, and you should make it deliberately too. Returning a zero value for an empty slice hides the caller’s bug (was the minimum really 0, or was the slice empty?). Panicking, or returning (T, bool) like First did, surfaces it. The standard library’s slices.Min panics; matching that convention is the least surprising thing you can do.

Utilities that need comparable: Unique, Keys, Values

Some helpers need comparable not for == in a comparison but because they use T as a map key. Unique is the clearest case: the only fast way to dedupe is a set, and a Go set is map[T]struct{}, which forces T to be comparable.

package main

import "fmt"

// Unique returns the elements of src with duplicates removed, keeping
// first-seen order. Needs comparable so values can be map keys.
func Unique[T comparable](src []T) []T {
	seen := make(map[T]struct{}, len(src))
	out := make([]T, 0, len(src))
	for _, v := range src {
		if _, ok := seen[v]; ok {
			continue
		}
		seen[v] = struct{}{}
		out = append(out, v)
	}
	return out
}

// Keys returns the keys of m in unspecified order.
func Keys[K comparable, V any](m map[K]V) []K {
	out := make([]K, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	return out
}

// Values returns the values of m in unspecified order.
func Values[K comparable, V any](m map[K]V) []V {
	out := make([]V, 0, len(m))
	for _, v := range m {
		out = append(out, v)
	}
	return out
}

func main() {
	visitors := []string{"ada", "grace", "ada", "katherine", "grace"}
	fmt.Println("unique:", Unique(visitors))

	limits := map[string]int{"free": 100, "pro": 10000}
	fmt.Println("keys count:", len(Keys(limits)))
	fmt.Println("values sum:", Values(limits)[0]+Values(limits)[1])
}
unique: [ada grace katherine]
keys count: 2
values sum: 10100

Keys and Values show the map pattern: K is comparable because every map key type must be (the language requires it), while V is any because a value can be anything. The map[T]struct{} inside Unique uses an empty struct as the value type because it occupies zero bytes; you only care whether the key is present.

Keys and Values return elements in the map’s randomized iteration order, so I marked that in the doc comment. If a caller wants a stable result they sort afterward. That is also why, as the next section explains, you probably should not write Keys at all.

Chunk and GroupBy: the two that pull their weight in real code

Chunk and GroupBy are the utilities I miss most from other languages, and neither is in the standard library. Chunk batches a slice (paginating, or splitting work across goroutines). GroupBy buckets elements by a derived key.

package main

import "fmt"

// Chunk splits src into consecutive slices of at most size elements.
func Chunk[T any](src []T, size int) [][]T {
	if size <= 0 {
		panic("Chunk: size must be positive")
	}
	out := make([][]T, 0, (len(src)+size-1)/size)
	for i := 0; i < len(src); i += size {
		end := min(i+size, len(src))
		out = append(out, src[i:end])
	}
	return out
}

// GroupBy buckets elements by the key that keyFn derives from each.
func GroupBy[T any, K comparable](src []T, keyFn func(T) K) map[K][]T {
	groups := make(map[K][]T)
	for _, v := range src {
		key := keyFn(v)
		groups[key] = append(groups[key], v)
	}
	return groups
}

func main() {
	ids := []int{1, 2, 3, 4, 5, 6, 7}
	fmt.Println("chunks:", Chunk(ids, 3))

	words := []string{"apple", "avocado", "banana", "cherry", "cranberry"}
	byLetter := GroupBy(words, func(w string) byte { return w[0] })
	fmt.Printf("a: %v\n", byLetter['a'])
	fmt.Printf("b: %v\n", byLetter['b'])
	fmt.Printf("c: %v\n", byLetter['c'])
}
chunks: [[1 2 3] [4 5 6] [7]]
a: [apple avocado]
b: [banana]
c: [cherry cranberry]

GroupBy[T any, K comparable] is the most useful two-parameter shape in the toolkit. T is any (the elements can be anything), K is comparable because it becomes a map key. The append(groups[key], ...) line leans on a Go convenience: indexing a missing map key returns a nil slice, and appending to a nil slice allocates a fresh one, so you never initialize buckets yourself.

Chunk uses min, a built-in since Go 1.21, so you do not need your own helper for it. It also validates size up front and panics on a nonsensical zero or negative, because silently returning an empty result would hide a caller bug.

What the standard library already covers: do not reinvent slices and maps

Here is the honest part most tutorials skip. Since Go 1.21 the slices and maps packages ship generic helpers, and several functions above already exist there. Writing your own Contains, IndexOf, Min, Max, or Keys is reinventing tested, optimized stdlib code.

package main

import (
	"cmp"
	"fmt"
	"slices"
)

type Server struct {
	Host string
	Load int
}

func main() {
	codes := []int{200, 404, 200, 500}

	fmt.Println("contains 404:", slices.Contains(codes, 404))
	fmt.Println("index of 500:", slices.Index(codes, 500))
	fmt.Println("max:         ", slices.Max(codes))

	fleet := []Server{
		{"web-3", 42},
		{"web-1", 91},
		{"web-2", 17},
	}
	slices.SortFunc(fleet, func(a, b Server) int {
		return cmp.Compare(a.Load, b.Load)
	})
	fmt.Println("by load:     ", fleet)

	deduped := slices.Clone(codes)
	slices.Sort(deduped)
	deduped = slices.Compact(deduped)
	fmt.Println("compacted:   ", deduped)
}
contains 404: true
index of 500: 3
max:          500
by load:      [{web-2 17} {web-3 42} {web-1 91}]
compacted:    [200 404 500]

slices.Contains and slices.Index replace the Contains/IndexOf we wrote by hand. slices.Max/slices.Min replace Max/Min. slices.SortFunc sorts by any comparison you supply, which covers sorting structs. And slices.Compact removes runs of adjacent equal elements, so slices.Sort followed by slices.Compact is a dedupe for cases where you do not need to preserve original order (note that differs from our Unique, which does preserve first-seen order). For maps, maps.Keys and maps.Values return iterators, and slices.Sorted(maps.Keys(m)) is the idiom for stable key order.

So which of our functions survive? The ones the stdlib does not provide: Map, Filter, Reduce, Unique (order-preserving), Chunk, and GroupBy. The Go team has repeatedly declined to add Map/Filter/Reduce to the standard library, so those are legitimately yours to write. Everything else, reach for slices and maps first. Full inventory in the slices and maps guide.

The cmp package: cmp.Compare and cmp.Or for sorting and tie-breaks

Two small cmp functions deserve their own moment because they make SortFunc pleasant. cmp.Compare(a, b) returns -1, 0, or +1, exactly the three-way result slices.SortFunc wants. cmp.Or returns its first non-zero argument, which turns multi-key sorting into a readable chain:

package main

import (
	"cmp"
	"fmt"
	"slices"
)

type Job struct {
	Name     string
	Priority int
	Created  int // unix seconds, lower is older
}

func main() {
	// cmp.Compare returns -1, 0, or +1.
	fmt.Println(cmp.Compare(3, 9))
	fmt.Println(cmp.Compare("zebra", "apple"))

	// cmp.Or returns the first non-zero argument. Perfect for tie-breaks.
	region := cmp.Or("", "", "us-east-1")
	fmt.Println("region:", region)

	jobs := []Job{
		{"resize", 2, 1000},
		{"email", 1, 1200},
		{"email-retry", 1, 900},
		{"backup", 2, 800},
	}
	// Sort by priority ascending, then oldest first as a tie-break.
	slices.SortFunc(jobs, func(a, b Job) int {
		return cmp.Or(
			cmp.Compare(a.Priority, b.Priority),
			cmp.Compare(a.Created, b.Created),
		)
	})
	for _, j := range jobs {
		fmt.Printf("%-12s prio=%d created=%d\n", j.Name, j.Priority, j.Created)
	}
}
-1
1
region: us-east-1
email-retry  prio=1 created=900
email        prio=1 created=1200
backup       prio=2 created=800
resize       prio=2 created=1000

Read the sort chain top to bottom: compare priorities first; if they tie (compare returns 0), cmp.Or moves to the created time. This composes to any number of keys without a pile of nested if statements. cmp.Or also has a plain use beyond sorting: cmp.Or(configValue, envValue, defaultValue) picks the first non-empty option, a clean way to express fallback defaults.

Layer 3: a collection-utilities package you can drop into a project

Individually these functions are snippets. The way you actually use them is a small internal package. Here is collections, the survivors from above, as a real package plus a program that runs an order report end to end.

The package (collections/collections.go):

// Package collections holds small, generic slice and map helpers that the
// standard library does not provide. Prefer slices/maps where they overlap.
package collections

// Map applies transform to every element and returns a new slice.
func Map[T, U any](src []T, transform func(T) U) []U {
	out := make([]U, len(src))
	for i, v := range src {
		out[i] = transform(v)
	}
	return out
}

// Filter keeps only the elements for which keep returns true.
func Filter[T any](src []T, keep func(T) bool) []T {
	out := make([]T, 0, len(src))
	for _, v := range src {
		if keep(v) {
			out = append(out, v)
		}
	}
	return out
}

// Reduce folds src into a single accumulator value.
func Reduce[T, A any](src []T, initial A, combine func(A, T) A) A {
	acc := initial
	for _, v := range src {
		acc = combine(acc, v)
	}
	return acc
}

// GroupBy buckets elements by the key derived from each.
func GroupBy[T any, K comparable](src []T, keyFn func(T) K) map[K][]T {
	groups := make(map[K][]T)
	for _, v := range src {
		key := keyFn(v)
		groups[key] = append(groups[key], v)
	}
	return groups
}

// Unique removes duplicates, keeping first-seen order.
func Unique[T comparable](src []T) []T {
	seen := make(map[T]struct{}, len(src))
	out := make([]T, 0, len(src))
	for _, v := range src {
		if _, ok := seen[v]; ok {
			continue
		}
		seen[v] = struct{}{}
		out = append(out, v)
	}
	return out
}

The caller (main.go):

package main

import (
	"fmt"
	"slices"

	"gen/collections"
)

type Order struct {
	Customer string
	Region   string
	Total    int // cents
}

func main() {
	orders := []Order{
		{"ada", "us-east", 1299},
		{"grace", "eu-west", 4500},
		{"ada", "us-east", 799},
		{"katherine", "us-east", 2100},
		{"grace", "eu-west", 150},
	}

	// Total revenue across all orders.
	revenue := collections.Reduce(orders, 0, func(acc int, o Order) int {
		return acc + o.Total
	})
	fmt.Printf("revenue: $%.2f\n", float64(revenue)/100)

	// Orders worth keeping for a report: at least $10.
	big := collections.Filter(orders, func(o Order) bool { return o.Total >= 1000 })
	fmt.Println("orders over $10:", len(big))

	// Distinct regions, sorted for a stable report.
	regions := collections.Unique(collections.Map(orders, func(o Order) string {
		return o.Region
	}))
	slices.Sort(regions)
	fmt.Println("regions:", regions)

	// Group by customer, then report each customer's spend in stable order.
	byCustomer := collections.GroupBy(orders, func(o Order) string { return o.Customer })
	names := make([]string, 0, len(byCustomer))
	for name := range byCustomer {
		names = append(names, name)
	}
	slices.Sort(names)
	for _, name := range names {
		spend := collections.Reduce(byCustomer[name], 0, func(acc int, o Order) int {
			return acc + o.Total
		})
		fmt.Printf("  %-10s $%.2f (%d orders)\n", name, float64(spend)/100, len(byCustomer[name]))
	}
}
revenue: $88.48
orders over $10: 3
regions: [eu-west us-east]
  ada        $20.98 (2 orders)
  grace      $46.50 (2 orders)
  katherine  $21.00 (1 orders)

That is the whole point of generic functions in one screen: Reduce, Filter, Map, Unique, and GroupBy all operate on your Order type without a single line changed, and the report reads as a description of what you want rather than a stack of index loops. In a real service this package sits in internal/collections, gets a _test.go file with table-driven tests, and you never think about it again. That test file is where you would apply the patterns from the Go tutorial on writing tests.

Common mistakes

Rewriting a function the standard library already ships

The most expensive mistake is invisible: writing Contains, Index, Min, Max, Reverse, or Keys by hand in 2026. They live in slices and maps, are fuzz-tested, and are recognized on sight by every Go reviewer. Before you write a generic slice helper, grep the slices package. Write your own only for what is genuinely missing (Map, Filter, Reduce, GroupBy, Chunk, order-preserving Unique).

Using any where the body needs comparable

If your function compares values with == or uses them as map keys, any will not compile:

func Contains[T any](s []T, target T) bool {
	for _, v := range s {
		if v == target { // won't compile
			return true
		}
	}
	return false
}
./main.go:5:6: invalid operation: v == target (incomparable types in type set)

The fix is [T comparable]. But comparable has its own edge: it excludes slices, maps, and functions, so comparable still rejects some types at the call site, again at compile time:

rows := [][]int{{1, 2}, {3, 4}}
Contains(rows, []int{1, 2}) // []int is not comparable
./main.go:16:22: []int does not satisfy comparable

That is the language protecting you: == on two []int values is not defined, so a generic Contains cannot promise it. If you must compare slices, take a custom equality function instead, the way slices.ContainsFunc does.

Writing a generic function where a concrete one is clearer

Generics are a tool for removing duplication, not a default. If a function only ever operates on []Order, write func totalRevenue(orders []Order) int. A concrete signature is easier to read, gives better error messages, and lets the reader see exactly what it does. The rule of thumb: reach for a type parameter when you have the same body for two or more real types today, not when you imagine you might. A single-use Map[Order, string] wrapper is worse than the three-line loop it replaces.

Ignoring cmp and slices when sorting

Hand-rolling a bubble sort or a custom comparator struct to sort by two fields is a lot of code to get wrong. slices.SortFunc with a cmp.Or(cmp.Compare(...), cmp.Compare(...)) chain is three lines, correct, and stable in intent. When you see nested comparison if statements in a review, that is the smell that cmp.Or was forgotten.

What next

You can now write a generic function, pick its constraint on purpose, and tell your utilities apart from the ones the standard library already gives you. From here:

Worth bookmarking: the introduction to generics on the Go blog and the cmp package docs.