How to Write a Generic Function in Go

Define a generic function in Go by adding type parameters in square brackets after the function name to handle multiple types.

The copy-paste trap

You write a function to find the maximum of two integers. It works. Then you need the same logic for floats. You copy the function, rename it, change the types. Then you need it for strings. Suddenly you have MaxInt, MaxFloat, and MaxString. The logic is identical, but the type signatures force you to duplicate code. Every time you fix a bug in MaxInt, you have to remember to fix it in the other versions. Testing multiplies. Maintenance becomes a chore.

Go 1.18 introduced type parameters to solve this. You write the logic once and let the compiler generate the specialized versions. The function accepts a placeholder for the type, and the compiler fills in the blank when you call it. This eliminates duplication while keeping the type safety that Go is known for.

A blueprint with a blank space

Think of a generic function as a blueprint with a blank space for the type. You define the behavior, but you leave the specific type open until someone calls the function. When you call Max(1, 2), the compiler fills in the blank with int. When you call Max("a", "b"), it fills in string. The function body stays the same; only the type changes.

This is called parametric polymorphism, but "generics" is the word everyone uses. The key insight is that the type parameter is resolved at compile time. There is no runtime cost for the generic machinery. The compiler generates concrete code for each type you use.

Minimal example

Here's the simplest generic function: a type parameter in square brackets, a constraint, and the body that uses the type parameter.

package main

import "fmt"

// Max returns the larger of two comparable values.
// The [T comparable] syntax declares a type parameter T constrained to the comparable interface.
func Max[T comparable](a, b T) T {
	// The compiler checks that T supports the < operator because of the comparable constraint.
	if a < b {
		return b
	}
	return a
}

func main() {
	// The compiler infers T as int from the arguments.
	fmt.Println(Max(1, 2))
	// The compiler infers T as string from the arguments.
	fmt.Println(Max("a", "b"))
}

The square brackets [T comparable] declare the type parameter. T is the name you choose for the placeholder. comparable is the constraint. Constraints tell the compiler which types are allowed. comparable is a built-in interface that includes types supporting == and !=. It covers ints, floats, strings, pointers, and structs with comparable fields. It excludes slices, maps, and functions because you can't compare those for equality.

Inside the function, T acts like a normal type. You can use it for parameters, return values, and local variables. The compiler checks that every operation on T is valid for the constraint. If you try to use < on a type that only supports ==, the compiler catches the error.

Generics are for the compiler, not the runtime. Trust the monomorphization to keep performance tight.

How the compiler handles generics

Go generics are monomorphized. The compiler generates a separate copy of the function for each type you use. If you call Max with int and string, the compiled binary contains two distinct functions: one for ints and one for strings. There is no runtime overhead for the type parameter itself. The type information disappears after compilation, except for reflection. This keeps generics fast.

Go strikes a balance between C++ templates and Java type erasure. C++ templates can cause binary bloat and slow compile times because the compiler expands templates aggressively. Java erases types at runtime, which breaks some operations and requires runtime checks. Go generates code only for the types you actually use. If you never call Max with string, no string version is generated. This keeps binaries lean.

Type inference handles most calls. The compiler looks at the arguments to guess the type parameter. If you call Max(1, 2), it sees two ints and infers T = int. You rarely need to write the type explicitly. When inference fails, you can provide the type manually. Max[int](1, 2) forces the type. This is useful when calling a generic function with no arguments, or when you want to be explicit about the type. The syntax is FunctionName[Type](args).

Convention: gofmt handles generic formatting perfectly. You don't need to worry about spacing around brackets or constraints. Run gofmt on save and let the tool decide the layout. Most editors integrate gofmt automatically. Argue logic, not formatting.

Realistic example: Slice helpers

Here's a realistic helper: a generic Contains function that works for slices of any comparable type.

package main

import "fmt"

// Contains checks if a value exists in a slice.
// The constraint ensures the element type supports equality checks.
func Contains[T comparable](slice []T, target T) bool {
	// Range over the slice and compare items directly.
	for _, item := range slice {
		if item == target {
			return true
		}
	}
	return false
}

func main() {
	// Works for int slices without explicit type arguments.
	fmt.Println(Contains([]int{1, 2, 3}, 2))
	// Works for string slices too.
	fmt.Println(Contains([]string{"go", "faq"}, "rust"))
}

This function works for []int, []string, []MyStruct, and any other slice where the element type is comparable. The constraint ensures the == operator is valid. Without the constraint, the compiler would reject the equality check. You can't write a generic function that compares arbitrary types because not all types support comparison. The constraint acts as a contract. The caller promises to provide a type that satisfies the constraint, and the function promises to work correctly for any such type.

Constraints define the boundary. Code inside the function assumes the constraint holds. If the constraint is wrong, the compiler catches the error immediately.

Custom constraints

Built-in constraints like comparable cover many cases. Sometimes you need a custom constraint. You can define your own constraints using interface syntax. This unifies generics and interfaces. A constraint is just an interface that a type parameter must satisfy.

Here's how to define a custom constraint when built-in interfaces aren't enough.

package main

import "fmt"

// Number is a custom constraint that accepts int, float64, and string types.
// The ~ prefix matches the underlying type, allowing named types like MyInt.
type Number interface {
	~int | ~float64 | ~string
}

// Sum adds values of any type that satisfies the Number constraint.
// The return type is the same as the input type.
func Sum[T Number](values []T) T {
	var total T
	// Zero value of T is used as the initial accumulator.
	// This works because all constrained types have a valid zero value.
	for _, v := range values {
		total += v
	}
	return total
}

func main() {
	// The compiler infers T as int.
	fmt.Println(Sum([]int{1, 2, 3}))
	// The compiler infers T as float64.
	fmt.Println(Sum([]float64{1.5, 2.5}))
	// Strings concatenate with +=.
	fmt.Println(Sum([]string{"a", "b", "c"}))
}

The Number interface uses a union type. The ~ prefix means "underlying type". ~int matches int and any named type with an underlying int. This allows the constraint to accept custom types like type MyInt int. The | operator creates a union. T must be one of the listed types. The Sum function uses += on T. The compiler checks that every type in the union supports +=. If you add ~bool to the union, the compiler rejects the function because bool doesn't support +=.

Convention: Receiver naming still applies to generic structs. Use a short name matching the type. (s *Stack[T]) Push(item T) is correct. (this *Stack[T]) is not. The receiver name is usually one or two letters matching the type.

Pitfalls and compiler errors

Pitfalls appear when type inference fails or constraints are too loose. The compiler complains with cannot infer T if the arguments have different types or if there's not enough information. For example, Max(1, 2.0) fails because there is no common type. You must convert one argument or provide the type explicitly. Max[int](1, int(2.0)) works.

The compiler rejects this with invalid operation: operator < not defined on T if you forget the constraint or use the wrong one. If you write func Max[T any](a, b T) T and use <, the compiler stops you. any allows all types, including slices and maps, which don't support comparison. Always use the tightest constraint that allows your logic to work. comparable is better than any when you need equality checks.

Generic methods on structs have a restriction. You can't add a generic method to a concrete struct. The compiler rejects this with method must have a receiver or syntax errors. You have to make the struct generic or use a standalone function. type Stack struct { items []int } cannot have a generic method. type Stack[T any] struct { items []T } can have methods that use T.

Convention: Generic structs are less common than generic functions. Go idioms often prefer interfaces for polymorphism. Use generic structs when you need to store a collection of a specific type and the type matters for the struct's behavior. A Set[T] or Cache[K, V] are good candidates. For simple operations, a generic function is usually enough.

Convention: context.Context always goes as the first parameter, even in generic functions. func Do[T comparable](ctx context.Context, val T). Functions that take a context should respect cancellation and deadlines. Don't bury the context behind a type parameter.

The worst generic bug is the one where the constraint is too loose and the function panics at runtime. Trust the compiler to enforce the rules.

When to use generics

Use a generic function when you need to operate on the type itself, such as creating a slice of T or returning a value of type T.

Use an interface when you only need to call methods on the value and don't care about the underlying type.

Use a concrete type when the function only works with one specific type and adding flexibility adds unnecessary complexity.

Use any when you truly need to accept any type and will use type assertions or reflection inside the function.

Don't make everything generic. Specificity is a feature. If a function only works with strings, write it for strings. Generics add cognitive load. Use them when the benefit of code reuse outweighs the complexity.

Generics are a tool for the compiler. Use them to eliminate duplication, not to build type systems on type systems.

Where to go next