A logo showing the text blog.marcnuri.com
Español
Home»Go»Go Generics Tutorial: A Complete Introduction to Type Parameters in Go 1.18+

Recent Posts

  • Fabric8 Kubernetes Client 7.4 is now available!
  • Kubernetes MCP Server Joins the Containers Organization!
  • MCP Tool Annotations: Adding Metadata and Context to Your AI Tools
  • Fabric8 Kubernetes Client 7.2 is now available!
  • Connecting to an MCP Server from JavaScript using AI SDK

Categories

  • Artificial Intelligence
  • Front-end
  • Go
  • Industry and business
  • Java
  • JavaScript
  • Legacy
  • Operations
  • Personal
  • Pet projects
  • Tools

Archives

  • September 2025
  • July 2025
  • May 2025
  • April 2025
  • March 2025
  • February 2025
  • January 2025
  • December 2024
  • November 2024
  • August 2024
  • June 2024
  • May 2024
  • April 2024
  • March 2024
  • February 2024
  • January 2024
  • December 2023
  • November 2023
  • October 2023
  • September 2023
  • August 2023
  • July 2023
  • June 2023
  • May 2023
  • April 2023
  • March 2023
  • February 2023
  • January 2023
  • December 2022
  • November 2022
  • October 2022
  • September 2022
  • August 2022
  • July 2022
  • June 2022
  • May 2022
  • March 2022
  • February 2022
  • January 2022
  • December 2021
  • November 2021
  • October 2021
  • September 2021
  • August 2021
  • July 2021
  • January 2021
  • December 2020
  • November 2020
  • October 2020
  • September 2020
  • August 2020
  • July 2020
  • June 2020
  • May 2020
  • February 2020
  • January 2020
  • December 2019
  • October 2019
  • September 2019
  • July 2019
  • March 2019
  • November 2018
  • July 2018
  • June 2018
  • May 2018
  • April 2018
  • March 2018
  • February 2018
  • December 2017
  • October 2017
  • August 2017
  • July 2017
  • January 2017
  • December 2015
  • November 2015
  • December 2014
  • March 2014
  • February 2011
  • November 2008
  • June 2008
  • May 2008
  • April 2008
  • January 2008
  • November 2007
  • September 2007
  • August 2007
  • July 2007
  • June 2007
  • May 2007
  • April 2007
  • March 2007

Go Generics Tutorial: A Complete Introduction to Type Parameters in Go 1.18+

2022-03-26 in Go tagged Go by Marc Nuri | Last updated: 2025-09-15
Versión en Español

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:

basic_generics.go
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 restricts T 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:

type_inference.go
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:

standard_constraints.go
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 the constraints.Ordered constraint, allowing it to work with any type that supports ordering (integers, floats, strings).
  • The Multiply function uses a union of constraints.Integer and constraints.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:

custom_constraints.go
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 a String() method.
  • Type union constraints: Numeric accepts multiple specific types using the | operator.
  • Custom type implementation: Person type implementing the Stringer 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:

multiple_type_params.go
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 type T to a slice of type U 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 a GetAll method that returns all key-value pairs as a slice of Pair[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:

method_constraints.go
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 a Distance() method.
  • Both Point2D and Point3D structs implement the Distanceable interface.
  • The FilterWithinDistance[T Distanceable] function filters a slice of any type that implements Distanceable, 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:

http_generics.go
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:

  1. Type Safety: The compiler ensures correct types at build time, eliminating runtime type assertion errors.
  2. Code Reusability: One client implementation works with any API response structure.
  3. Self-Documenting: Method signatures clearly show what types are expected and returned.
  4. 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:

performance_example.go
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:

  1. Use meaningful constraint names: Choose descriptive names for your type parameters and constraints.
  2. Prefer specific constraints: Use the most specific constraint possible rather than any.
  3. Keep type parameters minimal: Don't add type parameters unless they provide clear value.
  4. Use type inference: Let the compiler infer types when possible.
  5. Consider readability: Generics should make code more reusable without sacrificing clarity.

The following code snippet illustrates some of these best practices with examples:

best_practices.go
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:

avoid_over_generics.go
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:

constraint_confusion.go
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:

zero_value_handling.go
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.

You might also like

  • Go Concurrency Patterns: Goroutines and Channels
  • How to set up and tear down unit tests in Go
  • Getting started with Testcontainers for Go
  • Testing Go Gin Web Framework REST APIs with httptest
Twitter iconFacebook iconLinkedIn iconPinterest iconEmail icon

Post navigation
Eclipse JKube 1.8.0 is now available!Pattern Matching for instanceof in Java
© 2007 - 2025 Marc Nuri