Go Generics: Practical Cases Without Overengineering
Oct 2026 · 5 min read
Generics let a single implementation work for multiple types with compile-time checking. Use type parameters when the algorithm is truly the same for many types, then restrict operations through the smallest possible constraint.
Full code: https://github.com/Sch39/go-series
Problem
Duplication easily appears when the same algorithm is needed for multiple types. Replacing everything with any does reduce duplication, but it removes type information and moves errors to runtime.
Outcome
Readers can:
Distinguish cases suited for generic functions and generic types.
Define constraints based on the operations actually needed.
Use type inference and named types without losing compile-time checking.
Implementation
Folder Structure
go-series/G01-08-generics/
├── go.mod
└── generic/
├── generic.go
└── generic_test.goSetup
Initialize a single module (run from the go-series/ folder):
mkdir -p G01-08-generics
cd G01-08-generics
go mod init github.com/sch39/go-series/g0108/genericsCode Blocks
Block 1: Constraint comparable — type-safe search
go-series/G01-08-generics/generic/generic.go:
package generic
type Number interface {
~int | ~int64 | ~float64
}
func Contains[T comparable](values []T, target T) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}Goal: Restrict Contains to types that support the == comparison operation.
Block 2: Type set — numeric operations across named types
go-series/G01-08-generics/generic/generic.go:
func Sum[T Number](values []T) T {
var total T
for _, value := range values {
total += value
}
return total
}Goal: Sum several numeric types, including named types whose underlying type is allowed by the constraint.
Block 3: Two type parameters — element type transformation
go-series/G01-08-generics/generic/generic.go:
func Map[T, R any](values []T, transform func(T) R) []R {
result := make([]R, len(values))
for i, value := range values {
result[i] = transform(value)
}
return result
}Goal: Convert a slice of type T into a slice of type R without type assertions.
Block 4: Generic type — reusable stack
go-series/G01-08-generics/generic/generic.go:
type Stack[T any] struct {
values []T
}
func (s *Stack[T]) Push(value T) {
s.values = append(s.values, value)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.values) == 0 {
var zero T
return zero, false
}
last := len(s.values) - 1
value := s.values[last]
s.values = s.values[:last]
return value, true
}Goal: Store and retrieve elements with a type that stays consistent within a single stack instance.
Test Blocks
Test 1: Type inference and named numeric type
go-series/G01-08-generics/generic/generic_test.go:
package generic_test
import (
"strconv"
"testing"
"github.com/sch39/go-series/g0108/generics/generic"
)
type Score int
func TestContainsAndSum(t *testing.T) {
values := []Score{10, 20, 30}
found := generic.Contains(values, Score(20))
total := generic.Sum(values)
t.Logf("found=%v total=%d", found, total)
if !found {
t.Error("Contains returned false, want true")
}
if total != 60 {
t.Errorf("Sum returned %d, want 60", total)
}
}Goal: Prove the compiler infers the type argument and that ~int accepts the named type Score.
Test 2: Transformation produces a different type
go-series/G01-08-generics/generic/generic_test.go:
func TestMapChangesElementType(t *testing.T) {
values := []int{10, 20, 30}
strings := generic.Map(values, strconv.Itoa)
t.Logf("values=%v strings=%v", values, strings)
if len(strings) != 3 || strings[0] != "10" || strings[2] != "30" {
t.Errorf("Map returned %v, want [10 20 30]", strings)
}
}Goal: Prove Map maintains the relationship between input and output types without runtime conversion.
Test 3: Stack returns zero value when empty
go-series/G01-08-generics/generic/generic_test.go:
func TestStack(t *testing.T) {
var stack generic.Stack[string]
stack.Push("first")
stack.Push("second")
value, ok := stack.Pop()
t.Logf("value=%q ok=%v", value, ok)
if !ok || value != "second" {
t.Errorf("Pop returned %q, %v; want second, true", value, ok)
}
stack.Pop()
zero, ok := stack.Pop()
t.Logf("empty value=%q ok=%v", zero, ok)
if ok || zero != "" {
t.Errorf("empty Pop returned %q, %v; want empty string, false", zero, ok)
}
}Goal: Prove the generic type preserves LIFO and produces the zero value of T when there are no elements.
Run Test
Run from the go-series/G01-08-generics/ folder:
go test -count=1 -v ./...Output:
=== RUN TestContainsAndSum
generic_test.go:17: found=true total=60
--- PASS: TestContainsAndSum (0.00s)
=== RUN TestMapChangesElementType
generic_test.go:30: values=[10 20 30] strings=[10 20 30]
--- PASS: TestMapChangesElementType (0.00s)
=== RUN TestStack
generic_test.go:43: value="second" ok=true
generic_test.go:50: empty value="" ok=false
--- PASS: TestStack (0.00s)
PASS
ok github.com/sch39/go-series/g0108/generics/generic 3.241sWhy It Works
Type parameters and inference: Type parameters are written in square brackets and always have a constraint. When calling a generic function, the compiler can infer the type argument from the function arguments, so the call Contains(values, target) does not need to explicitly write [Score].
Constraints and type sets: A constraint is an interface that defines the set of allowed types as well as the operations that are safe for the body of the generic function. comparable allows == and !=, while the union ~int | ~int64 | ~float64 allows numeric operations that apply to all members of that type set.
Approximation element ~: The term ~int includes int and named types whose underlying type is int. That is why Score satisfies Number; without ~, the constraint would only accept the exact types listed.
Edge Cases
Zero value of a type parameter: Generic code cannot write a universal zero literal for all T. The declaration var zero T produces the correct zero value for the type argument in use.
comparable is not ordered: This constraint only guarantees == and !=; it does not allow < or >. Sorting algorithms require a constraint that expresses an ordered type set.
Methods cannot have additional type parameters: A generic type may have methods, but a method cannot declare its own type parameters. If an operation needs a new type parameter, use a package-level generic function or move that parameter to the receiver type.
Trade-offs
Strategy | Pro | Con |
|---|---|---|
Generic function | Type-safe and reduces algorithm duplication | Constraints can add API complexity |
Interface method | Suited for behavioral variation and runtime polymorphism | Does not represent built-in operators such as |
Concrete implementation | Simplest and easiest to read | Duplication appears if the same algorithm is needed for many types |
Best practice: Start from concrete code; extract into generics once it becomes clear that the same algorithm is used by multiple types. Make the constraint as small as the operations needed, not a large constraint for future possibilities.
Gotchas I Experienced
The named type
Scoresatisfies the constraint~int; the termintwithout~only includes theinttype itself.var zero Tproduces""whenStack[string]is empty, while the success status still has to be distinguished through theboolvalue.
Production Checklist
Is the algorithm truly the same for more than one type? Consider generics.
Does the body only use operations guaranteed by the constraint? Narrow the constraint if needed.
Is the API clear enough without explicit type arguments? Make sure inference works for common calls.
Is an interface more appropriate for behavioral variation? Do not replace it with generics without reason.
References
Go Tutorial, Getting started with generics: https://go.dev/doc/tutorial/generics
The Go Programming Language Specification, Interface types: https://go.dev/ref/spec#Interface_types
Go FAQ, Type Parameters: https://go.dev/doc/faq#generics