Generics in Go: what problem they actually solve
Generics in Go with practical examples: generic functions, constraints, collections and when not to use them. No over-engineering.

Go survived 13 years without generics. Enormous production systems were built, CLIs used by everyone and one of the largest cloud infrastructures on the planet. Without generics. And yet, it worked. When they arrived in Go 1.18 (March 2022), the community split between those who had spent a decade asking for the feature and those who feared Go would turn into Java.
Four years later, the verdict is clear: generics solve real problems, but the risk of overusing them is equally real. I have seen Go code with type parameters nested three levels deep that nobody on the team could read. And I have seen five-line generic functions that eliminated hundreds of lines of duplicated code.
This article is about understanding what problem generics solve, when they are the right tool and, above all, when they are not. With real code, no academic abstractions.
The problem generics solve
Before Go 1.18, if you wanted to write a function that operated on different types, you had exactly two options: duplicate code or use interface{} (today any).
Imagine you need a function that returns the minimum value of a slice. Without generics:
func MinInt(values []int) int {
min := values[0]
for _, v := range values[1:] {
if v < min {
min = v
}
}
return min
}
func MinFloat64(values []float64) float64 {
min := values[0]
for _, v := range values[1:] {
if v < min {
min = v
}
}
return min
}
func MinString(values []string) string {
min := values[0]
for _, v := range values[1:] {
if v < min {
min = v
}
}
return min
}Three identical functions. Same logic, different type. If you find a bug, you fix it three times. If you need int32, you copy again. This is not a theoretical example: the Go standard library before 1.18 was full of functions like sort.Ints, sort.Float64s, sort.Strings, each doing exactly the same thing.
The alternative was to use interface{}:
func Min(values []interface{}) interface{} {
// How do you compare two interface{}?
// You can't use < directly.
// You need type assertions or reflection.
// You lose type safety at compile time.
// Your IDE can't help you.
// Runtime panic if someone passes an unexpected type.
}This is not a solution. It is a patch that trades one problem (duplication) for a worse one (losing type safety). In languages like Java or Kotlin, generics have been solving this for decades. Go decided not to include them for 13 years because the team could not find a design that fit the language’s philosophy. And honestly, I think they were right to wait.
Basic syntax: type parameters and constraints
The syntax for generics in Go is deliberately simple. If you come from Java or TypeScript, it will feel minimalist. If you only program in Go, it is new but not difficult.
A generic function is defined with type parameters in square brackets:
func Min[T cmp.Ordered](values []T) T {
min := values[0]
for _, v := range values[1:] {
if v < min {
min = v
}
}
return min
}Breaking it down:
[T cmp.Ordered]: declares a type parameterTwith thecmp.OrderedconstraintTis a placeholder: when you call the function, Go substitutesTwith the concrete typecmp.Orderedis the constraint that says: “T can be any type that supports the<,>,<=,>=operators”
To use it:
fmt.Println(Min([]int{3, 1, 4, 1, 5})) // 1
fmt.Println(Min([]float64{2.7, 1.4, 3.1})) // 1.4
fmt.Println(Min([]string{"go", "rust", "java"})) // goGo infers the type in most cases. You do not need to write Min[int](...) explicitly, although you can if there is ambiguity.
Multiple type parameters
You can have more than one type parameter:
func Map[T any, R any](slice []T, fn func(T) R) []R {
result := make([]R, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
// Usage
names := Map([]int{1, 2, 3}, func(n int) string {
return fmt.Sprintf("item-%d", n)
})
// ["item-1", "item-2", "item-3"]Here T is the input type and R the output type. Each has its own constraint (in this case, any, which is an alias for interface{} and allows any type).
Constraints: what operations your type can perform
A constraint defines what a type parameter can do. Without constraints, a type parameter cannot do anything other than be assigned and passed as an argument. You cannot add two T values, you cannot compare them, you cannot do anything useful. The constraint is what gives capabilities to the generic type.
any
The most permissive constraint. Equivalent to interface{}. Your generic type can only be stored, passed as an argument and returned. You cannot operate on it.
func Identity[T any](v T) T {
return v
}comparable
Allows using == and !=. Necessary to use a type as a map key or compare values:
func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
Contains([]string{"go", "rust", "python"}, "go") // true
Contains([]int{1, 2, 3}, 4) // falsecmp.Ordered
Since Go 1.21, the cmp package in the standard library defines Ordered, which includes all types that support ordering operators (<, >, <=, >=). This covers all numeric types and string.
import "cmp"
func Clamp[T cmp.Ordered](value, min, max T) T {
if value < min {
return min
}
if value > max {
return max
}
return value
}
Clamp(15, 0, 10) // 10
Clamp(-5, 0, 10) // 0
Clamp(7, 0, 10) // 7The golang.org/x/exp/constraints package
Before cmp.Ordered arrived in the standard library, the experimental constraints package defined types like Integer, Float, Signed, Unsigned, Complex and Ordered. As of today (2026), for most cases cmp.Ordered and comparable cover what you need. The experimental package is still useful if you need more granular constraints such as “only signed integers”.
Custom constraints
You can define your own constraints using interfaces. This is where generics in Go get interesting:
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~float32 | ~float64
}
func Sum[T Number](values []T) T {
var total T
for _, v := range values {
total += v
}
return total
}The ~ operator means “this type or any type whose underlying type is this”. This is important because in Go you can define types like type UserID int. Without ~, UserID would not satisfy an int constraint. With ~int, it does.
The | operator is a type union: “int OR float32 OR float64”.
Generic functions: practical examples
Let’s get to what matters. These are generic functions I have used or seen in real production code. They are not academic exercises.
Filter
func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
// Filter active users
active := Filter(users, func(u User) bool {
return u.Active
})
// Filter positive numbers
positive := Filter([]int{-3, -1, 0, 2, 5}, func(n int) bool {
return n > 0
})Find
func Find[T any](slice []T, predicate func(T) bool) (T, bool) {
for _, v := range slice {
if predicate(v) {
return v, true
}
}
var zero T
return zero, false
}
// Find a user by email
user, found := Find(users, func(u User) bool {
return u.Email == "roger@oshy.tech"
})Notice var zero T: this is the idiomatic pattern in Go for obtaining the zero value of a generic type.
Reduce
func Reduce[T any, R any](slice []T, initial R, fn func(R, T) R) R {
result := initial
for _, v := range slice {
result = fn(result, v)
}
return result
}
// Sum ages
totalAge := Reduce(users, 0, func(acc int, u User) int {
return acc + u.Age
})
// Concatenate names
names := Reduce(users, "", func(acc string, u User) string {
if acc == "" {
return u.Name
}
return acc + ", " + u.Name
})GroupBy
func GroupBy[T any, K comparable](slice []T, keyFn func(T) K) map[K][]T {
result := make(map[K][]T)
for _, v := range slice {
key := keyFn(v)
result[key] = append(result[key], v)
}
return result
}
// Group users by role
byRole := GroupBy(users, func(u User) string {
return u.Role
})Keys and Values of a map
func Keys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
func Values[K comparable, V any](m map[K]V) []V {
values := make([]V, 0, len(m))
for _, v := range m {
values = append(values, v)
}
return values
}These functions now exist as maps.Keys and maps.Values in the standard library (since Go 1.23 with iterators, previously in golang.org/x/exp/maps). But the pattern is the same.
Generic types: type-safe collections
Generics are not only for functions. You can define complete generic types. This is especially useful for data structures and wrappers.
Generic Set
Go does not have a native Set type. With generics, you can build a type-safe one:
type Set[T comparable] struct {
items map[T]struct{}
}
func NewSet[T comparable](values ...T) *Set[T] {
s := &Set[T]{items: make(map[T]struct{})}
for _, v := range values {
s.Add(v)
}
return s
}
func (s *Set[T]) Add(value T) {
s.items[value] = struct{}{}
}
func (s *Set[T]) Contains(value T) bool {
_, ok := s.items[value]
return ok
}
func (s *Set[T]) Remove(value T) {
delete(s.items, value)
}
func (s *Set[T]) Len() int {
return len(s.items)
}
func (s *Set[T]) Values() []T {
values := make([]T, 0, len(s.items))
for k := range s.items {
values = append(values, k)
}
return values
}Usage:
tags := NewSet("go", "backend", "generics")
tags.Add("testing")
tags.Contains("go") // true
tags.Contains("python") // false
tags.Len() // 4Before generics, this was done with map[string]struct{} directly, without encapsulation. Or with a wrapper over interface{} that lost type safety. Now you have a Set[string], a Set[int], a Set[UserID], all with compile-time type safety.
Result type
A pattern I have found useful for operations that can fail in ways different from a simple error:
type Result[T any] struct {
Value T
Err error
}
func OK[T any](value T) Result[T] {
return Result[T]{Value: value}
}
func Fail[T any](err error) Result[T] {
return Result[T]{Err: err}
}
func (r Result[T]) IsOK() bool {
return r.Err == nil
}
func (r Result[T]) Unwrap() (T, error) {
return r.Value, r.Err
}This does not replace the (T, error) pattern in Go, which remains idiomatic and preferable in most cases. But it is useful when working with pipelines or when you need to pass results as values:
func ProcessBatch[T any](items []T, process func(T) Result[T]) []Result[T] {
results := make([]Result[T], len(items))
for i, item := range items {
results[i] = process(item)
}
return results
}Generic Stack
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
func (s *Stack[T]) Peek() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
return s.items[len(s.items)-1], true
}
func (s *Stack[T]) Len() int {
return len(s.items)
}Constraints with interface combinations
This is where generics in Go show something other languages do not have as cleanly: you can combine methods and types in a constraint.
Constraint with methods
type Stringer interface {
String() string
}
func JoinStrings[T Stringer](items []T, sep string) string {
parts := make([]string, len(items))
for i, item := range items {
parts[i] = item.String()
}
return strings.Join(parts, sep)
}Constraint with types and methods
type OrderedStringer interface {
cmp.Ordered
String() string
}This says: “the type must support ordering operators AND have a String() method”. In practice this is uncommon because basic types (int, string) do not have methods. But it is useful for user-defined types:
type Priority int
func (p Priority) String() string {
switch p {
case 1:
return "low"
case 2:
return "medium"
case 3:
return "high"
default:
return "unknown"
}
}Constraint with type union
type Numeric interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
func Avg[T Numeric](values []T) float64 {
if len(values) == 0 {
return 0
}
var sum T
for _, v := range values {
sum += v
}
return float64(sum) / float64(len(values))
}When to use generics
Generics make sense in specific situations. Not in all of them.
Utility functions over collections
This is the clearest use case and where generics shine. Filter, Map, Reduce, Find, GroupBy, Contains, Unique… all these functions operate on the structure of the data, not on its meaning. They are perfect candidates for generics.
The standard library already includes many of them with the slices package (Go 1.21+):
import "slices"
slices.Sort(numbers)
slices.Contains(names, "go")
idx := slices.Index(items, target)
slices.Reverse(data)Generic data structures
Sets, stacks, queues, linked lists, trees, LRU caches… any data structure that is independent of the stored type benefits from generics. Before you had to choose between type safety and reusability. Now you can have both.
Reducing repetitive boilerplate
If you have the same pattern repeated for multiple types and the logic is identical except for the type, generics are the right solution. But be careful: if the logic changes between types, it is not a generics problem, it is a design problem.
Wrappers and adapters
type Cache[K comparable, V any] struct {
data map[K]V
mu sync.RWMutex
maxSize int
}
func NewCache[K comparable, V any](maxSize int) *Cache[K, V] {
return &Cache[K, V]{
data: make(map[K]V),
maxSize: maxSize,
}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}A cache that works with string, int, UserID or any comparable type as a key, and any type as a value. Type-safe at compile time. This previously required interface{} everywhere.
When NOT to use generics
This is as important as knowing when to use them. Perhaps more so.
Most business logic
Your CreateOrder function, your HTTP handler, your authentication service… none of this needs generics. Business logic operates on concrete types with concrete rules. If your ProcessPayment function takes a type parameter, something has gone wrong.
// DO NOT do this
func ProcessPayment[T PaymentMethod](method T, amount float64) error {
// ...
}
// Do this
func ProcessPayment(method PaymentMethod, amount float64) error {
// ...
}The second version uses a normal interface. It is simpler, more readable and you gain nothing with generics here because the interface already gives you the polymorphism you need.
When the code works fine without them
If your function operates on a single type and you see no need to reuse it with other types, do not make it generic “just in case”. YAGNI applies strongly here. Go is a language that rewards simplicity. A func SumPrices(prices []float64) float64 is perfectly valid if you only sum prices.
When readability suffers
// This is readable
func MergeSlices[T any](a, b []T) []T
// This starts to be questionable
func Transform[T any, R any, E error](items []T, fn func(T) (R, E)) ([]R, E)
// Nobody understands this at first glance
func Pipeline[I any, M any, O any](
input []I,
stage1 func(I) (M, error),
stage2 func(M) (O, error),
) ([]O, error)Each type parameter adds cognitive load. If you need more than two or three, you are probably over-engineering.
For “future flexibility”
Do not write generic code because “maybe someday I will need other types”. Write it when you need it. Refactoring a concrete function to a generic one is trivial in Go. The cost of maintaining premature abstraction is not.
Generics vs interfaces: choosing the right abstraction
This is the question I have seen most often in teams starting with generics: “do I use an interface or a type parameter?”
The short answer: if you need different behaviour per type, use interfaces. If you need the same logic for different types, use generics.
Interfaces: behavioural polymorphism
type Storage interface {
Save(ctx context.Context, key string, data []byte) error
Load(ctx context.Context, key string) ([]byte, error)
}
// S3Storage, RedisStorage, FileStorage implement Storage
// Each with different logicHere each implementation does something different. There is no logic duplication. Interfaces are the right tool.
Generics: type polymorphism
func SortSlice[T cmp.Ordered](s []T) {
slices.Sort(s)
}Here the logic is the same regardless of whether you sort int, string or float64. Only the type changes. Generics are the right tool.
The grey area
Sometimes you need both:
type Repository[T any] interface {
FindByID(ctx context.Context, id string) (T, error)
Save(ctx context.Context, entity T) error
Delete(ctx context.Context, id string) error
}A generic interface. This is valid and useful when you have multiple repositories (users, products, orders) that follow the same contract but with different types. Each concrete implementation can have different logic, but the contract is the same.
That said, if your project only has two repositories, you probably do not need this abstraction. Two concrete interfaces UserRepository and ProductRepository are simpler and clearer. If you get to five or ten, the generic version starts to justify itself.
If you want to dive deeper into how Go uses interfaces in general, you can read about it in Effective Go explained.
Common mistakes with generics
I have seen these mistakes repeatedly in code reviews. Try not to fall into them.
Making generic what does not need it
// Unnecessary. Only used with strings.
func ParseConfig[T ~string](input T) Config {
// ...
}
// Better
func ParseConfig(input string) Config {
// ...
}If your function is only used with one type, the type parameter is noise.
Overly complex constraints
type Processable interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64 | ~complex64 | ~complex128 |
~string
fmt.Stringer
json.Marshaler
}If your constraint needs an entire screen to define, your design has a problem. Simplify or use a normal interface.
Not handling the zero value
// Bug: panic if the slice is empty
func First[T any](slice []T) T {
return slice[0]
}
// Correct: returns the zero value and a bool
func First[T any](slice []T) (T, bool) {
if len(slice) == 0 {
var zero T
return zero, false
}
return slice[0], true
}Always handle the empty case. The (T, bool) pattern is idiomatic in Go and works perfectly with generics.
Forgetting that type parameters cannot be used in methods
This is a real limitation of Go (as of 2026): methods cannot have their own type parameters. They can only use the type parameters of the receiver type.
type Container[T any] struct {
items []T
}
// VALID: uses T from the receiver type
func (c *Container[T]) Add(item T) {
c.items = append(c.items, item)
}
// DOES NOT COMPILE: methods cannot declare new type parameters
// func (c *Container[T]) Map[R any](fn func(T) R) []R { ... }The solution is to use a free function instead of a method:
func MapContainer[T any, R any](c *Container[T], fn func(T) R) []R {
result := make([]R, len(c.items))
for i, item := range c.items {
result[i] = fn(item)
}
return result
}It is not the most elegant, but it is the design Go chose to keep type inference simple and compilation fast.
The state of generics in the Go ecosystem (2026)
After four years with generics, the ecosystem has found a fairly healthy balance.
The standard library
The slices, maps and cmp packages are the biggest beneficiaries of generics. Functions that previously required external packages or duplicated code are now in the stdlib:
import (
"cmp"
"maps"
"slices"
)
// Sort a slice of any comparable type
slices.Sort(numbers)
slices.SortFunc(users, func(a, b User) int {
return cmp.Compare(a.Name, b.Name)
})
// Operations on maps
maps.Clone(original)
maps.DeleteFunc(m, func(k string, v int) bool {
return v == 0
})Iterators (Go 1.23) combined with generics have opened up functional patterns that were not previously possible in Go in an ergonomic way.
Community libraries
Packages like samber/lo (a Lodash-style library for Go) use generics extensively and are very popular. samber/lo has Filter, Map, Reduce, Chunk, GroupBy, Uniq and dozens more. If you need collection utilities and do not want to write them yourself, it is a solid option.
Other notable libraries:
hashicorp/go-set: generic sets with set operationsemirpashas/gods: generic data structures (trees, queues, stacks)sourcegraph/conc: type-safe concurrency with generics
What is still missing
Generics in Go are deliberately limited. There is no:
- Type specialization: you cannot have a different implementation of a generic function for a concrete type
- Type parameters in methods: as mentioned earlier, methods cannot declare their own type parameters
- Higher-kinded types: you cannot abstract over type constructors (no
FunctororMonad) - Variadic type parameters: you cannot have a variable number of type parameters
Some of these limitations are being actively discussed. But Go remains Go: if something complicates the language without a clear and massive benefit, it will probably not be included.
Complete example: a generic processing pipeline
To close, an example that combines several concepts. A batch processing pipeline that filters, transforms and groups data of any type:
package pipeline
import "fmt"
// Stage represents a processing step
type Stage[T any] func([]T) []T
// Run executes stages sequentially
func Run[T any](data []T, stages ...Stage[T]) []T {
result := data
for _, stage := range stages {
result = stage(result)
}
return result
}
// FilterStage creates a filtering step
func FilterStage[T any](predicate func(T) bool) Stage[T] {
return func(items []T) []T {
var result []T
for _, item := range items {
if predicate(item) {
result = append(result, item)
}
}
return result
}
}
// Real usage
type Order struct {
ID string
Amount float64
Status string
}
func ProcessOrders(orders []Order) []Order {
return Run(orders,
FilterStage(func(o Order) bool {
return o.Status == "confirmed"
}),
FilterStage(func(o Order) bool {
return o.Amount > 100
}),
)
}Notice that ProcessOrders is a concrete function that uses the generic pipeline. The business logic is in concrete functions. Generics provide the reusable infrastructure. That is the right balance.
If you want to see how to test this kind of generic code, you can review how testing in Go handles functions with type parameters. And if you are organizing a Go project from scratch, it is worth checking out the conventions for project structure.
The right tool, not the default tool
Generics in Go solve a real problem: code duplication when the logic is identical for different types. Utility functions over collections, generic data structures and type-safe wrappers are the clear use cases.
But generics are not the answer to everything. Most of your Go code should remain concrete, direct and without type parameters. Go was designed to be simple, and generics are a tool to be used to maintain that simplicity, not to destroy it.
What has worked for me is always writing concrete code first and refactoring to generic only when the duplication is real and bothersome. If I need more than two type parameters, it is usually a sign that the design needs a different approach. And often an interface solves the same problem with less complexity.
Generics in Go are like spice in cooking: in the right amount they improve everything. In excess, they ruin the dish.


