Go Generics Tutorial: A Complete Introduction to Type Parameters in Go 1.18+
Introduction
Go generics were one of the most anticipated features in Go 1.18, fundamentally changing how we write reusable and type-safe code. Before generics, Go developers often relied on interface, reflection, or code generation to achieve type flexibility, each approach bringing trade-offs in performance, type safety, or maintainability.
With the introduction of type parameters, Go now provides a clean, efficient way to write generic functions and data structures while maintaining compile-time type safety. In this guide, I will take you through everything you need to know about Go generics, from basic concepts to advanced patterns, with practical examples you can use in production code.
Whether you're building utility functions, implementing custom data structures, or designing APIs that work with multiple types, understanding generics will make your Go code more expressive and maintainable.
Understanding Go Generics Basics
Go generics introduce the concept of type parameters, placeholders for types that are specified when the generic function or type is used. This allows you to write code that works with different types while maintaining type safety at compile time.
Your First Generic Function
Let's start with a simple example that demonstrates the core concept using some basic comparisons:
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// Generic function that works with any ordered type
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
// Compare Alex and Aitana's scores (int)
alexScore := 95
aitanaScore := 87
topScore := Max(alexScore, aitanaScore)
fmt.Printf("Highest score: %d\n", topScore)
// Compare two names alphabetically (string)
lastName := Max("Alex", "Aitana")
fmt.Printf("Name that comes last: %s\n", lastName)
// Compare temperatures (float64)
morningTemp := 15.5
afternoonTemp := 22.3
higherTemp := Max(morningTemp, afternoonTemp)
fmt.Printf("Higher temperature: %.1f°C\n", higherTemp)
}
In this example, Max[T constraints.Ordered]
defines a generic function where:
T
is the type parameter (can be any name,T
is conventional)constraints.Ordered
is a type constraint that restrictsT
to types that support comparison operators (<
,<=
,>
,>=
) as well as equality (==
,!=
)- The function works with any type that satisfies the
constraints.Ordered
constraint
Note
The constraints.Ordered
constraint includes types like integers, floats, and strings that can be compared for ordering. For equality-only operations, use the built-in comparable
constraint instead.
By using generics, we can write a single Max
function that works with multiple types without duplicating code or losing type safety.
Type Inference
One of Go's generics strengths is type inference, the compiler can often deduce type parameters from function arguments:
package main
import "fmt"
func Swap[T any](a, b T) (T, T) {
return b, a
}
func main() {
// Explicit type specification
x1, y1 := Swap[int](1, 2)
fmt.Printf("Explicit: %d, %d\n", x1, y1)
// Type inference (recommended)
x2, y2 := Swap("hello", "world")
fmt.Printf("Inferred: %s, %s\n", x2, y2)
}
The any
constraint (alias for interface{}
) allows any type.
In most cases, you can omit the type parameter when calling generic functions, as Go's compiler will infer the appropriate type.
In this example, Swap
works with any type, and the compiler infers the type based on the arguments provided.
The only constraint is that both parameters must be of the same type.
Type Constraints and Interfaces
Type constraints define what operations are allowed on type parameters.
Go 1.18 introduced the golang.org/x/exp/constraints
package with the most common constraints.
You can define custom constraints using interfaces too.
Built-in and Standard Constraints
Go provides several built-in and standard library constraints to help you write generic code safely and efficiently. These constraints, such as comparable
and those from the golang.org/x/exp/constraints
package, allow you to restrict type parameters to types that support specific operations like comparison or arithmetic.
Let's look at some examples:
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// Using built-in constraints
func Add[T constraints.Ordered](a, b T) T {
return a + b
}
// Using numeric constraints
func Multiply[T constraints.Integer | constraints.Float](a, b T) T {
return a * b
}
func main() {
// Works with any ordered type (integers, floats, strings)
result1 := Add(10, 20)
result2 := Add(1.5, 2.5)
result3 := Add("Hello, ", "Go!")
fmt.Printf("Add results: %d, %.1f, %s\n", result1, result2, result3)
// Works with numeric types only
product1 := Multiply(5, 4)
product2 := Multiply(2.5, 3.0)
fmt.Printf("Multiply results: %d, %.1f\n", product1, product2)
}
In this example:
- The
Add
function uses theconstraints.Ordered
constraint, allowing it to work with any type that supports ordering (integers, floats, strings). - The
Multiply
function uses a union ofconstraints.Integer
andconstraints.Float
, restricting it to numeric types only.
Built-in constraints cover common use cases, but you can also create your own custom constraints for more specific requirements. Let's see how to do that.
Custom Type Constraints
You can define custom constraints using interface types:
package main
import "fmt"
// Custom constraint for types that can be converted to string
type Stringer interface {
String() string
}
// Custom constraint using type union
type Numeric interface {
int | int32 | int64 | float32 | float64
}
// Generic function using custom constraint
func PrintValue[T Stringer](value T) {
fmt.Println("Value:", value.String())
}
// Generic function with numeric constraint
func Average[T Numeric](values []T) T {
if len(values) == 0 {
var zero T
return zero
}
var sum T
for _, v := range values {
sum += v
}
return sum / T(len(values))
}
// Example type implementing Stringer
type Person struct {
Name string
Age int
}
func (p Person) String() string {
return fmt.Sprintf("%s (%d years old)", p.Name, p.Age)
}
func main() {
person := Person{Name: "Julia", Age: 30}
PrintValue(person)
intValues := []int{1, 2, 3, 4, 5}
floatValues := []float64{1.1, 2.2, 3.3}
fmt.Printf("Int average: %d\n", Average(intValues))
fmt.Printf("Float average: %.1f\n", Average(floatValues))
}
In this example we can see several important concepts:
- Interface-based constraints:
Stringer
requires types to implement aString()
method. - Type union constraints:
Numeric
accepts multiple specific types using the|
operator. - Custom type implementation:
Person
type implementing theStringer
interface.
Advanced Generic Patterns
As you become more comfortable with basic generics, you can explore more sophisticated patterns that leverage the full power of Go's type system. These patterns become particularly useful when building complex applications that need to work with multiple types while maintaining strict type safety.
Multiple Type Parameters
Functions and types can have multiple type parameters:
package main
import "fmt"
// Generic map function with two type parameters
func MapSlice[T any, U any](slice []T, mapper func(T) U) []U {
result := make([]U, len(slice))
for i, item := range slice {
result[i] = mapper(item)
}
return result
}
// Generic key-value pair
type Pair[K comparable, V any] struct {
Key K
Value V
}
// Generic cache with multiple type parameters
type Cache[K comparable, V any] struct {
data map[K]V
}
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{
data: make(map[K]V),
}
}
func (c *Cache[K, V]) Set(key K, value V) {
c.data[key] = value
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
value, exists := c.data[key]
return value, exists
}
func (c *Cache[K, V]) GetAll() []Pair[K, V] {
pairs := make([]Pair[K, V], 0, len(c.data))
for k, v := range c.data {
pairs = append(pairs, Pair[K, V]{Key: k, Value: v})
}
return pairs
}
func main() {
// Map integers to strings
numbers := []int{1, 2, 3, 4, 5}
strings := MapSlice(numbers, func(n int) string {
return fmt.Sprintf("Number: %d", n)
})
fmt.Printf("Mapped strings: %v\n", strings)
// String-to-int cache
intCache := NewCache[string, int]()
intCache.Set("age", 42)
intCache.Set("year", 2023)
if value, exists := intCache.Get("age"); exists {
fmt.Printf("Age: %d\n", value)
}
// Int-to-string cache
stringCache := NewCache[int, string]()
stringCache.Set(1, "one")
stringCache.Set(2, "two")
fmt.Printf("String cache contents: %v\n", stringCache.GetAll())
}
In this example, we can see how to define functions and types with multiple type parameters:
MapSlice[T any, U any]
maps a slice of typeT
to a slice of typeU
using a provided mapping function.Pair[K comparable, V any]
represents a key-value pair with generic key and value types.Cache[K comparable, V any]
is a simple in-memory cache that can store values of any type indexed by keys of any comparable type.
It implements aGetAll
method that returns all key-value pairs as a slice ofPair[K, V]
, this is a good example to see how to use generics in struct methods.
This pattern is particularly useful for building reusable data structures and algorithms that can operate on various types without sacrificing type safety.
Type Constraints with Methods
You can create constraints that require specific methods by defining interfaces:
package main
import (
"fmt"
"math"
)
// Distanceable is an interface used as a Constraint requiring a Distance method
type Distanceable interface {
Distance() float64
}
// Point2D struct that implements Distanceable
type Point2D struct {
X, Y float64
}
func (p Point2D) Distance() float64 {
return math.Sqrt(p.X*p.X + p.Y*p.Y)
}
// Point3D struct that implements Distanceable
type Point3D struct {
X, Y, Z float64
}
func (p Point3D) Distance() float64 {
return math.Sqrt(p.X*p.X + p.Y*p.Y + p.Z*p.Z)
}
// Generic function to filter points within distance
func FilterWithinDistance[T Distanceable](points []T, maxDistance float64) []T {
result := make([]T, 0)
for _, point := range points {
if point.Distance() <= maxDistance {
result = append(result, point)
}
}
return result
}
func main() {
// 2D points
points2D := []Point2D{
{X: 1, Y: 1},
{X: 3, Y: 4},
{X: 0.5, Y: 0.5},
{X: 2, Y: 1},
}
near2D := FilterWithinDistance(points2D, 2.0)
fmt.Printf("2D Points within distance 2.0: %v\n", near2D)
// 3D points
points3D := []Point3D{
{X: 1, Y: 1, Z: 1},
{X: 2, Y: 2, Z: 2},
{X: 0.3, Y: 0.4, Z: 0.5},
}
near3D := FilterWithinDistance(points3D, 1.0)
fmt.Printf("3D Points within distance 3.0: %v\n", near3D)
}
In this example:
- We define a
Distanceable
interface that requires aDistance()
method. - Both
Point2D
andPoint3D
structs implement theDistanceable
interface. - The
FilterWithinDistance[T Distanceable]
function filters a slice of any type that implementsDistanceable
, returning only those points within a specified maximum distance.
This pattern is powerful for creating algorithms that operate on any type that satisfies a specific behavior, as defined by the methods in the interface.
Real-World Use Cases
A practical and illustrative use case for Go generics is building type-safe HTTP clients. This pattern effectively demonstrates the benefits of generics by eliminating repetitive type assertions while maintaining compile-time safety across different API endpoints.
Generic HTTP Response Handler
The following example demonstrates a generic HTTP client that can handle different response types without sacrificing type safety.
This approach is superior to using interface{}
because the compiler validates types at build time, preventing runtime panics and making the code self-documenting:
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// Generic response structure that wraps any data type
type APIResponse[T any] struct {
Data T `json:"data"`
Success bool `json:"success"`
Message string `json:"message"`
}
// HTTP client that can work with any API response type
type HTTPClient[T any] struct {
baseURL string
client *http.Client
}
func NewHTTPClient[T any](baseURL string) *HTTPClient[T] {
return &HTTPClient[T]{
baseURL: baseURL,
client: &http.Client{},
}
}
// Generic GET method that returns type-safe responses
func (h *HTTPClient[T]) Get(endpoint string) (*APIResponse[T], error) {
url := h.baseURL + endpoint
resp, err := h.client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var response APIResponse[T]
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, err
}
return &response, nil
}
// Generic POST method with separate types for request and response
func (h *HTTPClient[T]) Post(endpoint string, payload any) (*APIResponse[T], error) {
url := h.baseURL + endpoint
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, err
}
resp, err := h.client.Post(url, "application/json", strings.NewReader(string(jsonData)))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var response APIResponse[T]
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, err
}
return &response, nil
}
// Example data structures for blog.marcnuri.com API
type BlogPost struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
ReadTime string `json:"readTime"`
URL string `json:"url"`
}
type CreatePostRequest struct {
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
}
func main() {
// Create client for blog.marcnuri.com API
client := NewHTTPClient[BlogPost]("https://api.blog.marcnuri.com")
// Type-safe GET request - compiler knows response contains BlogPost
if blogResponse, err := client.Get("/posts/1"); err == nil {
fmt.Printf("Blog post: %s by %s\n", blogResponse.Data.Title, blogResponse.Data.Author)
}
// Type-safe POST request with different input/output types
newPost := CreatePostRequest{
Title: "Go Generics in Practice",
Content: "A comprehensive guide...",
Author: "Marc",
}
if createResponse, err := client.Post("/posts", newPost); err == nil {
fmt.Printf("Created post with ID: %d at %s\n", createResponse.Data.ID, createResponse.Data.URL)
}
}
The example above demonstrates working with a hypothetical blog.marcnuri.com API, showing how the same HTTP client code can handle different endpoints with complete type safety.
The BlogPost
and CreatePostRequest
types represent the structure of data exchanged with the API, and the generic client ensures that each endpoint returns exactly the expected type.
This generic HTTP client pattern offers several advantages:
- Type Safety: The compiler ensures correct types at build time, eliminating runtime type assertion errors.
- Code Reusability: One client implementation works with any API response structure.
- Self-Documenting: Method signatures clearly show what types are expected and returned.
- Performance: No runtime overhead from type assertions or reflection.
In practice, this pattern is particularly useful when building applications that interact with REST APIs, microservices, or any system where you need to work with structured data from external sources while maintaining Go's strong typing guarantees.
Performance Considerations
Go generics are implemented using type specialization at compile time, meaning there's typically no runtime performance penalty compared to type-specific implementations:
package main
import (
"fmt"
"time"
)
// Generic sum function
func SumGeneric[T ~int](values []T) T {
var sum T
for _, v := range values {
sum += v
}
return sum
}
// Non-generic equivalent
func SumInt(values []int) int {
var sum int
for _, v := range values {
sum += v
}
return sum
}
func benchmark(fn func()) time.Duration {
start := time.Now()
fn()
return time.Since(start)
}
func main() {
// Large slice for benchmarking
values := make([]int, 10000000)
for i := range values {
values[i] = i
}
genericSum := benchmark(func() {
_ = SumGeneric(values)
})
fmt.Printf("Generic Sum took: %v\n", genericSum)
nonGenericSum := benchmark(func() {
_ = SumInt(values)
})
fmt.Printf("Non-Generic Sum took: %v\n", nonGenericSum)
// Performance is typically identical
fmt.Println("Performance of generic and non-generic versions should be nearly identical")
}
The performance benchmark demonstrates that generics in Go have zero runtime overhead. This is because Go implements generics through type specialization at compile time rather than runtime type erasure (like Java) or virtual dispatch.
When the Go compiler encounters SumGeneric[int]
, it generates specialized code equivalent to the non-generic SumInt
function.
This means you get the type safety and reusability benefits of generics without paying any performance penalty.
The generated assembly code for both functions will be virtually identical.
Note
The simple benchmark above is primarily for illustration. For accurate performance measurements, use Go's testing
package with go test -bench
for proper statistical analysis and more reliable results.
Best Practices
When using Go generics, following best practices will help you write clean, maintainable, and efficient code.
Here are some guidelines to consider:
- Use meaningful constraint names: Choose descriptive names for your type parameters and constraints.
- Prefer specific constraints: Use the most specific constraint possible rather than
any
. - Keep type parameters minimal: Don't add type parameters unless they provide clear value.
- Use type inference: Let the compiler infer types when possible.
- Consider readability: Generics should make code more reusable without sacrificing clarity.
The following code snippet illustrates some of these best practices with examples:
package main
import "golang.org/x/exp/constraints"
// ✅ Good: Descriptive constraint name and specific constraint
func FindMax[Number constraints.Ordered](values []Number) (Number, bool) {
if len(values) == 0 {
var zero Number
return zero, false
}
max := values[0]
for _, v := range values[1:] {
if v > max {
max = v
}
}
return max, true
}
// ❌ Less ideal: Generic constraint when specific one would work
func FindMaxAny[T any](values []T, compare func(a, b T) bool) (T, bool) {
if len(values) == 0 {
var zero T
return zero, false
}
max := values[0]
for _, v := range values[1:] {
if compare(v, max) {
max = v
}
}
return max, true
}
// ✅ Good: Type inference usage
func ExampleUsage() {
numbers := []int{1, 5, 3, 9, 2}
// Type is inferred, no need to specify [int]
max, found := FindMax(numbers)
if found {
println("Max:", max)
}
}
Now that we've covered best practices, let's look at some common pitfalls and how to avoid them.
Common Pitfalls and How to Avoid Them
While Go generics are powerful, there are several common mistakes that developers make when first adopting them. Understanding these pitfalls will help you write cleaner, more maintainable generic code and avoid frustrating compilation errors.
Over-Generic Code
Don't make everything generic. Use generics when you have a clear need for type flexibility:
package main
// ❌ Bad: Unnecessary generics
func PrintGeneric[T any](value T) {
println(value) // This doesn't compile! println doesn't accept any
}
// ✅ Better: Use generics only when needed
func PrintString(value string) {
println(value)
}
// ✅ Good: Generics provide clear value
func SafeGet[T any](slice []T, index int) (T, bool) {
var zero T
if index < 0 || index >= len(slice) {
return zero, false
}
return slice[index], true
}
Type Constraint Confusion
Understand what operations your constraints allow:
package main
import "golang.org/x/exp/constraints"
// ❌ This won't compile - 'any' doesn't support comparison
// func Compare[T any](a, b T) bool {
// return a == b
// }
// ✅ Correct constraint for comparison
func Compare[T comparable](a, b T) bool {
return a == b
}
// ✅ Correct constraint for ordering
func Min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
Zero Value Handling
Be careful when returning zero values of generic types:
package main
import "errors"
// ✅ Good: Clear error handling with zero values
func GetFirst[T any](slice []T) (T, error) {
var zero T
if len(slice) == 0 {
return zero, errors.New("slice is empty")
}
return slice[0], nil
}
// ✅ Alternative: Use pointers for optional values
func GetFirstPtr[T any](slice []T) *T {
if len(slice) == 0 {
return nil
}
return &slice[0]
}
func main() {
numbers := []int{1, 2, 3}
// Using error return
if first, err := GetFirst(numbers); err == nil {
println("First:", first)
}
// Using pointer return
if first := GetFirstPtr(numbers); first != nil {
println("First:", *first)
}
}
Conclusion
Go generics provide a powerful way to write type-safe, reusable code without sacrificing performance or Go's simplicity. The key to using generics effectively is understanding when they add value and applying them judiciously.
With the introduction of type parameters in Go 1.18, you can now:
- Write functions and data structures that work with multiple types.
- Maintain compile-time type safety.
- Reduce code duplication.
- Build more expressive APIs.
Start by identifying repeated patterns in your codebase where generics could eliminate duplication. Focus on utility functions, data structures, and APIs that naturally work with multiple types. Remember that the best generic code is code that feels natural and improves maintainability without adding unnecessary complexity.
As you become more comfortable with generics, explore advanced patterns like custom constraints and multiple type parameters.