Go Programming

Unlocking Power and Safety: Practical Go Generics Examples for Modern Development

Since the introduction of Go 1.18, Generics have become one of the most anticipated features in the language's history. For years, Go developers relied on code generation tools like stringer or manual interface assertions to achieve type safety without boilerplate. While effective, these workarounds often obscured intent and added complexity to the build process.

Generics allow us to write code that operates on any type, constrained by specific requirements, without sacrificing type safety or performance. For intermediate and advanced developers, understanding how to leverage these constraints effectively is crucial for writing maintainable, DRY (Don't Repeat Yourself) code. In this post, we will explore practical examples of Go generics, moving beyond simple "Hello World" snippets to real-world scenarios.

Eliminating Boilerplate with Generic Functions

The most common use case for generics is eliminating repetitive functions that differ only by their type. Consider a scenario where you need to find the minimum value in a collection of numbers. Without generics, you would need separate functions for int, float64, and int64.

With generics, we can define a single function using a type parameter T and a constraint. The standard library provides the comparable interface, which matches any type that supports equality checks (== and !=). However, for finding minimums, we often need ordering. Go's standard library doesn't provide a built-in ordered constraint, but we can create our own.

package main

import (
	"fmt"
)

// Ordered is a constraint that we define for types that support comparison
type Ordered interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
	~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
	~float32 | ~float64 | ~string
}

// FindMin returns the smallest value in a slice of any ordered type.
func FindMin[T Ordered](s []T) (T, bool) {
	if len(s) == 0 {
		var zero T
		return zero, false
	}
	min := s[0]
	for _, v := range s[1:] {
		if v < min {
			min = v
		}
	}
	return min, true
}

func main() {
	ints := []int{3, 1, 4, 1, 5, 9}
	if min, ok := FindMin(ints); ok {
		fmt.Printf("Min int: %d\n", min)
	}

	floats := []float64{1.1, 2.2, 0.5}
	if min, ok := FindMin(floats); ok {
		fmt.Printf("Min float: %.1f\n", min)
	}
}

Notice the use of the tilde (~) in the constraint. This indicates that the type parameter can be any type whose underlying type is one of the specified types (e.g., a custom type based on int). This flexibility is key to making generics truly reusable.

Generic Types for Collections

Another powerful application of generics is creating generic data structures. Let's implement a simple, type-safe stack. Unlike slices, which can grow dynamically, a stack often has a fixed capacity or specific push/pop semantics.

package main

import (
	"errors"
	"fmt"
)

type Stack[T any] struct {
	items []T
}

// NewStack creates a new stack with the specified capacity.
func NewStack[T any](capacity int) *Stack[T] {
	return &Stack[T]{
		items: make([]T, 0, capacity),
	}
}

// Push adds an item to the top of the stack.
func (s *Stack[T]) Push(item T) {
	s.items = append(s.items, item)
}

// Pop removes and returns the top item. Returns an error if empty.
func (s *Stack[T]) Pop() (T, error) {
	if len(s.items) == 0 {
		var zero T
		return zero, errors.New("stack is empty")
	}
	index := len(s.items) - 1
	item := s.items[index]
	s.items = s.items[:index]
	return item, nil
}

func main() {
	// Create a stack of strings
	stringStack := NewStack[string](10)
	stringStack.Push("First")
	stringStack.Push("Second")

	item, err := stringStack.Pop()
	if err != nil {
		fmt.Println(err)
	} else {
		fmt.Printf("Popped: %s\n", item) // Output: Popped: Second
	}
}

In this example, we use [T any]. The any type is an alias for interface{}, meaning it accepts any type. While this is less restrictive than the Ordered interface in the previous example, it allows us to create a generic stack that can hold integers, strings, or even custom structs without needing separate implementations.

Practical Advice: When to Use Generics

While generics are powerful, they are not a silver bullet. Here are a few guidelines to keep in mind:

  • Avoid Over-engineering: If you only need a function that works with two types, consider using an interface instead. Generics shine when you have multiple types or need to avoid reflection.
  • Readability Matters: Generic code can sometimes be harder to read than concrete code. Always strive to keep your constraints and type parameters simple. Avoid deeply nested constraints.
  • Stick to the Standard Library: Where possible, use the standard library's constraints (like comparable) or well-understood custom constraints to ensure consistency across your codebase.

Conclusion

Generics in Go represent a significant step forward in enabling code reuse while maintaining the language's core philosophy of simplicity and type safety. By mastering constraints and type parameters, you can write cleaner, more robust applications that scale with your team's needs. Start small: refactor one repetitive function or create one generic utility type, and gradually integrate these patterns into your projects.

As the Go ecosystem matures, we will likely see more sophisticated generic libraries and idioms emerge. Staying informed and practicing with these new tools will keep you at the forefront of modern Go development.

Share: