sch39
Part 8 of 9 — Go Fundamentals for Developers

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

text
go-series/G01-08-generics/
├── go.mod
└── generic/
    ├── generic.go
    └── generic_test.go

Setup

Initialize a single module (run from the go-series/ folder):

bash
mkdir -p G01-08-generics
cd G01-08-generics

go mod init github.com/sch39/go-series/g0108/generics

Code Blocks

Block 1: Constraint comparable — type-safe search

go-series/G01-08-generics/generic/generic.go:

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:

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:

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:

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:

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:

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:

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:

bash
go test -count=1 -v ./...

Output:

text
=== 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.241s

Why 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 Score satisfies the constraint ~int; the term int without ~ only includes the int type itself.

  • var zero T produces "" when Stack[string] is empty, while the success status still has to be distinguished through the bool value.

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