Go Programming Series: This lesson is part of our comprehensive Complete Go Tutorial: Learn Go (Golang) from Beginner to Advanced.
Generics in Go: Type Parameters, Constraints and Practical Generic Design
Generics allow Go code to operate over a family of types while retaining static type checking. They are useful for reusable data structures, algorithms, helpers, and APIs where the same logic applies to multiple concrete types. Generics are not a replacement for interfaces, and they are not a reason to make every helper generic. Strong Go designs use type parameters when they remove meaningful duplication while keeping the resulting API easy to understand.
What You Will Learn
- Why Go introduced generics and what problem they solve
- How to declare type parameters on functions and types
- How constraints define permitted types and operations
- How the ~ operator relates to underlying types
- How type inference works
- How comparable constraints are used
- How to build generic collections and helpers
- When interfaces are a better abstraction
- Common generic-design mistakes and testing strategies
1. What Are Generics?
Without generics, reusable functions often require interface{} or duplicated implementations for different concrete types. Generics allow a function or type to be parameterized by a type while the compiler checks operations against the declared constraint.
func Max[T ~int | ~float64](a, b T) T {
if a > b {
return a
}
return b
}
Here T is a type parameter. The constraint says T may be int, float64, or a defined type whose underlying type is int or float64. The function returns the same concrete type that was supplied.
2. Type Parameters
func First[T any](items []T) (T, bool) {
var zero T
if len(items) == 0 {
return zero, false
}
return items[0], true
}
The any constraint imposes no special operations on T. The function can store, return, or pass T values, but it cannot use operators that are unavailable for every possible T.
3. Constraints
A constraint describes which types are permitted and which operations the generic implementation can rely on. Narrow constraints are often easier to understand than overly broad ones.
type Number interface {
~int | ~int64 | ~float64
}
Constraints can be declared as named interfaces for reuse. They can also be written inline for small helpers.
4. Underlying Types and ~
The tilde operator allows a constraint to accept defined types whose underlying type matches. This matters because a named type such as type Score int is not identical to int, but it can still participate in a generic function constrained by ~int.
type Score int
func Double[T ~int](v T) T {
return v * 2
}
var s Score = 10
fmt.Println(Double(s))
5. Type Inference
The compiler can often infer type arguments from function arguments, keeping generic calls concise. Explicit type arguments can still be useful when inference cannot determine the intended type or when readability benefits from being explicit.
values := []int{1, 2, 3}
first, ok := First(values)
_ = first
_ = ok
6. Generic Types
Type parameters can be used on user-defined types. This is useful for reusable containers where the element type is part of the data structure's type.
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 { return zero, false }
i := len(s.items)-1
v := s.items[i]
s.items[i] = zero
s.items = s.items[:i]
return v, true
}
Zeroing the removed slot is useful when T may contain references because it can release those references before the backing slice is reused.
7. Generics and Interfaces
Generics and interfaces solve different problems. Generics are useful when an algorithm should work across types while preserving the concrete type. Interfaces are useful when different concrete types should satisfy a shared behavior and the caller can operate through that behavior.
| Question | Generics often fit | Interfaces often fit |
|---|---|---|
| Need same algorithm for many types? | Yes | Sometimes |
| Need behavior-based abstraction? | Sometimes | Yes |
| Need preserve concrete type? | Yes | Not necessarily |
| Need heterogeneous collection? | Usually not | Often |
| Need runtime polymorphism? | Not primarily | Yes |
8. Comparable Constraints
The comparable constraint permits types that can be compared with == and !=. It is useful for generic lookup structures and algorithms that need equality.
func Contains[T comparable](items []T, target T) bool {
for _, item := range items {
if item == target { return true }
}
return false
}
The constraint does not mean every type can be compared. Slices, maps, and functions remain non-comparable, so a generic function using comparable cannot be instantiated with those types.
9. Generic Maps and Sets
type Set[T comparable] map[T]struct{}
func (s Set[T]) Add(v T) {
s[v] = struct{}{}
}
func (s Set[T]) Has(v T) bool {
_, ok := s[v]
return ok
}
This is a good example of generics adding value: the set logic is reusable for every comparable key type without duplicating implementations for strings, ints, or defined key types.
10. Generic Algorithms
Generic algorithms are most valuable when the algorithm's logic is genuinely independent of the concrete element type. Examples include searching, filtering, mapping-like transformations, stacks, queues, heaps, and reusable sorting helpers. Check the standard library first because many common generic utilities already exist.
func Map[T any, U any](items []T, f func(T) U) []U {
out := make([]U, len(items))
for i, item := range items {
out[i] = f(item)
}
return out
}
11. When Not to Use Generics
Do not introduce a type parameter merely to make a short function look abstract. If a function naturally operates on one domain type, a concrete signature is often clearer. If behavior is the real abstraction, an interface may communicate the design better.
- Avoid generic wrappers that only forward to a concrete function.
- Do not create very broad constraints just to make code compile.
- Do not use generics to simulate inheritance.
- Prefer a concrete domain API when the operation has domain-specific meaning.
- Use interfaces when callers need interchangeable behavior rather than type-level reuse.
12. Constraints and API Design
A constraint is part of the API contract. It should express the operations or type family genuinely required by the implementation. Narrow constraints improve error messages and make the intended domain clearer.
// The implementation only needs comparable values.
func IndexOf[T comparable](items []T, target T) int { ... }
13. Performance Considerations
Do not assume a generic implementation is automatically faster or slower than an interface-based implementation. Performance depends on the concrete code and workload. Benchmark the actual path when allocation, CPU time, or latency matters.
14. Testing Generic Code
Generic code should be tested with representative types, including built-in types and defined types where constraints permit them. If the implementation relies on comparable values, test ordinary values, zero values, duplicates, and defined key types.
func TestContains(t *testing.T) {
if !Contains([]int{1,2,3}, 2) {
t.Fatal("expected value to be present")
}
type ID int
if !Contains([]ID{10,20}, ID(20)) {
t.Fatal("expected defined type to work")
}
}
15. Common Mistakes
- Using any when a narrower constraint is possible
- Assuming generics and interfaces are interchangeable
- Adding type parameters without a real reuse requirement
- Forgetting that operations must be supported by the constraint
- Using comparable with a type that contains non-comparable fields
- Creating overly complex constraints that are hard for callers to understand
- Ignoring standard-library generic helpers
16. Practical Design Checklist
Can the logic genuinely apply to multiple concrete types?
Would a concrete function be clearer?
Would an interface better express behavior?
What exact operations does the implementation need?
Can the constraint be narrowed?
Will callers understand the type parameter and its constraints?
Does the standard library already solve this problem?
Have representative concrete types been tested?
Frequently Asked Questions
What problem do Go generics solve?
They let reusable functions and types operate across compatible concrete types while retaining static type checking.
What does any mean in a type parameter?
any is an alias for interface{} and imposes no specific operations on the type parameter.
What does ~int mean in a constraint?
It permits int and defined types whose underlying type is int.
Are generics the same as interfaces?
No. Generics primarily provide type-level reuse; interfaces primarily provide behavior-based abstraction and runtime polymorphism.
What is comparable used for?
It constrains a type parameter to types that support == and !=, which is useful for generic equality and map/set patterns.
Should every reusable Go function be generic?
No. Use generics when they remove meaningful duplication or preserve useful type information. Otherwise a concrete function or interface may be clearer.
Do generics automatically improve performance?
Not necessarily. Performance depends on the concrete implementation and workload. Benchmark when performance is important.
Article Takeaways
- Generics provide reusable, statically checked code across compatible types.
- Constraints define permitted types and available operations.
- The ~ operator supports defined types by underlying type.
- Generics and interfaces address different design problems.
- Use narrow, understandable constraints.
- Do not make code generic without a real reuse benefit.
- Test generic code with multiple representative concrete types.
Conclusion
Generics are most valuable when they make a reusable abstraction both safer and clearer. Go's design keeps type parameters explicit enough to reason about constraints and operations without turning every program into a type-system exercise. Use generics where the algorithm is genuinely type-independent, use interfaces where behavior is the abstraction, and prefer the simplest API that accurately expresses the problem.