Post

23. Generics in Go

๐ŸŽ‰ Master Go generics introduced in Go 1.18! Learn type parameters, constraints, generic functions and types to write flexible, type-safe, reusable code. โœจ

23. Generics in Go

What we will learn in this post?

  • ๐Ÿ‘‰ Introduction to Generics
  • ๐Ÿ‘‰ Generic Functions
  • ๐Ÿ‘‰ Generic Types
  • ๐Ÿ‘‰ Type Constraints
  • ๐Ÿ‘‰ Generic Interfaces
  • ๐Ÿ‘‰ When to Use Generics

Go Generics: Type-Safe Reusability! ๐ŸŽ‰

Go 1.18 brought a super exciting feature: Generics! They let you write highly flexible, type-safe code that works across different data types without copying and pasting or needing complex reflection. This means more reusable components and less code duplication!

What are Generics? ๐Ÿ’ก

Imagine needing a function that finds the Max of two numbers. Before, youโ€™d write MaxInt(a, b int) and MaxFloat(a, b float64). Generics fix this by allowing you to define functions or types that operate on a type parameter, making them adaptable!

Meet Type Parameters: [T any] ๐Ÿงฑ

This syntax [T any] is key! T is your type parameter, a placeholder for any type. any (the new interface{}) means T can be any type, though you can use constraints like comparable for more specific needs (e.g., Max[T comparable](a, b T)).

Why Go Needed Them? ๐Ÿค”

Generics drastically reduce code duplication for common patterns like data structures (e.g., a generic Stack or Queue) or utility functions (like Map, Filter on slices). Before, these required specific implementations for each type, making code bulky and harder to maintain.

graph TD
    A["โŒ Without Generics"]:::style1 --> B["๐Ÿ“ Need Max for int?"]:::style2
    B --> C["โš™๏ธ Write MaxInt()"]:::style3
    A --> D["๐Ÿ“ Need Max for float64?"]:::style2
    D --> E["โš™๏ธ Write MaxFloat()"]:::style3
    A --> F["๐Ÿ˜ซ Lots of Duplication"]:::style5

    G["โœ… With Generics"]:::style4 --> H["๐Ÿ“ Need Max for any type?"]:::style2
    H --> I["๐Ÿš€ Write One Max[T comparable]"]:::style3
    G --> J["๐ŸŽ‰ One Reusable Function!"]:::style4

    classDef style1 fill:#ff4f81,stroke:#c43e3e,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style2 fill:#ffd700,stroke:#d99120,color:#222,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style3 fill:#6b5bff,stroke:#4a3f6b,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style4 fill:#43e97b,stroke:#38f9d7,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style5 fill:#ff9800,stroke:#f57c00,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;

    linkStyle default stroke:#e67e22,stroke-width:3px;

๐ŸŒŸ Unlock Flexible Code with Generic Functions!

Ever wish your functions could work with any data type without rewriting them? Generic functions make this possible! They let you write flexible, reusable code that adapts to various types, saving you time and effort.


๐Ÿ’ก Type Parameters: Your Flexible Placeholders

Imagine a placeholder like T (or U) in your functionโ€™s definition. This T represents any type youโ€™ll use later. Think of it as a blank slot that gets filled when you call the function, making your code incredibly versatile.

1
2
3
4
5
public T DoSomething<T>(T item)
{
    // 'item' can be an int, string, or any type!
    return item;
}

โ›“๏ธ Type Constraints: Adding Rules to Flexibility

Sometimes, you need T to have specific abilities (like being comparable to find a minimum). Type constraints add these rules using the where keyword. For Min/Max functions, IComparable<T> is perfect, ensuring the type can be ordered.

1
2
3
4
5
public T Min<T>(T a, T b) where T : IComparable<T>
{
    // Now we can use 'CompareTo' method!
    return a.CompareTo(b) < 0 ? a : b;
}

๐Ÿ”ฎ Type Inference: Smart & Simple Calling

The best part? You rarely need to explicitly specify the type when calling! The compiler is smart; it infers T from the arguments you pass, making your calls clean and concise.

1
2
3
4
5
int x = 5, y = 10;
int result = Min(x, y); // Compiler knows T is 'int'!

string s1 = "apple", s2 = "banana";
string smallest = Min(s1, s2); // Compiler knows T is 'string'!

โœจ Generic Min Function Example

Hereโ€™s our Min function in action, demonstrating all these concepts beautifully:

1
2
3
4
5
6
7
8
9
10
11
// Definition with type parameter <T> and constraint where T : IComparable<T>
public static T Min<T>(T a, T b) where T : IComparable<T>
{
    return a.CompareTo(b) < 0 ? a : b;
}

// Usage with type inference:
fmt.Println(Min(10, 5))            // Output: 5
fmt.Println(Min("apple", "banana")) // Output: apple
fmt.Println(Min(3.14, 2.71))       // Output: 2.71
}

๐Ÿ—บ๏ธ How Generics Work (Simplified)

graph TD
    A["โœ๏ธ Write Generic Function"]:::style1 --> B["๐Ÿ“‹ Define Type Parameters [T]"]:::style2
    B --> C["โ›“๏ธ Apply Type Constraints"]:::style3
    C --> D["โš™๏ธ Compile Flexible Code"]:::style4
    D --> E["๐Ÿ“ž Call Function with Data"]:::style5
    E --> F["๐Ÿ” Compiler Infers Type"]:::style3
    F --> G["๐Ÿš€ Execute Specific Version"]:::style4

    classDef style1 fill:#ff4f81,stroke:#c43e3e,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style2 fill:#6b5bff,stroke:#4a3f6b,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style3 fill:#ffd700,stroke:#d99120,color:#222,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style4 fill:#00bfae,stroke:#005f99,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style5 fill:#43e97b,stroke:#38f9d7,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;

    linkStyle default stroke:#e67e22,stroke-width:3px;

Go Generics: Write Flexible Code! ๐Ÿš€

Go generics let you write functions and data structures that work beautifully with any data type, not just one specific kind. Think of it as creating a super-reusable blueprint! This means less code duplication and more elegant solutions.

What are Generic Types? ๐Ÿค”

Goโ€™s built-in []T (slices) and map[K]V (maps) are already generic! Now, you can build your own custom generic types like Stack, List, or Tree. This avoids writing almost identical code for StackOfInts and StackOfStrings. You get strongly typed code with compile-time safety.

Building Custom Generic Structures โœจ

You define type parameters (like T or K) when declaring structs or interfaces. T acts as a placeholder for the actual type youโ€™ll use later. The any constraint means T can be any type!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// A generic Stack that can hold elements of any type `T`
type Stack[T any] struct {
    elements []T // The slice now holds items of type T
}

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

// Pop removes and returns the top item (simplified for brevity)
func (s *Stack[T]) Pop() T {
    lastIdx := len(s.elements) - 1
    item := s.elements[lastIdx]
    s.elements = s.elements[:lastIdx]
    return item
}

Imagine a Stack for numbers, or a Stack for strings โ€“ using the same Stack code!

Real-World Example: Generic Queue for Task Processing ๐Ÿ”„

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package main

import "fmt"

// Generic Queue that can hold any type of elements
type Queue[T any] struct {
    items []T
}

// Enqueue adds an item to the end of the queue
func (q *Queue[T]) Enqueue(item T) {
    q.items = append(q.items, item)
}

// Dequeue removes and returns the first item
func (q *Queue[T]) Dequeue() (T, bool) {
    if len(q.items) == 0 {
        var zero T
        return zero, false
    }
    item := q.items[0]
    q.items = q.items[1:]
    return item, true
}

// Size returns the number of items in the queue
func (q *Queue[T]) Size() int {
    return len(q.items)
}

type Task struct {
    ID   int
    Name string
}

func main() {
    // Queue for processing tasks
    taskQueue := &Queue[Task]{}
    taskQueue.Enqueue(Task{ID: 1, Name: "Send Email"})
    taskQueue.Enqueue(Task{ID: 2, Name: "Process Payment"})
    
    task, ok := taskQueue.Dequeue()
    if ok {
        fmt.Printf("Processing: %s\n", task.Name)
    }
    
    // Queue for integer processing
    intQueue := &Queue[int]{}
    intQueue.Enqueue(10)
    intQueue.Enqueue(20)
    fmt.Println("Queue size:", intQueue.Size()) // Output: Queue size: 2
}
graph TD
    A["๐Ÿ“‹ Generic Type Definition"]:::style1 --> B["๐Ÿท๏ธ Type Parameters<br/>[T any]"]:::style2
    B --> C["๐Ÿ—„๏ธ Internal Data Structure<br/>uses T (e.g., []T)"]:::style3
    C --> D["โš™๏ธ Methods operate on T"]:::style4
    D --> E["โœ… Type-Safe Operations"]:::style5

    classDef style1 fill:#ff4f81,stroke:#c43e3e,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style2 fill:#6b5bff,stroke:#4a3f6b,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style3 fill:#00bfae,stroke:#005f99,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style4 fill:#ffd700,stroke:#d99120,color:#222,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style5 fill:#43e97b,stroke:#38f9d7,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;

    linkStyle default stroke:#e67e22,stroke-width:3px;

Understanding Go Type Constraints โœจ

Goโ€™s type constraints empower you to write flexible yet safe generic code. They define what kinds of types a generic function or type can work with, ensuring operations are always valid.

Basic Constraints ๐Ÿ“š

  • any: This constraint is an alias for interface{}, meaning any type at all. Use it when your generic code doesnโ€™t need to perform specific operations on the type.
    • Example: func Log[T any](item T)
  • comparable: This constraint allows only types that can be compared using == and != (e.g., numbers, strings, booleans, pointers).
    • Example: func Contains[T comparable](slice []T, val T) bool

Custom Constraints with Interfaces ๐Ÿ› ๏ธ

You can define your own rules using interfaces. An interface can list methods, requiring types to implement them, or combine specific concrete types using | (OR).

1
2
3
type Numeric interface {
    ~int | ~float64 | ~string // This example is illustrative for combining types
}

The Tilde (~): Underlying Types ๐ŸŒŠ

The ~ (tilde) operator is powerful! ~MyType means โ€œeither MyType itself, or any type alias whose underlying type is MyType.โ€ This is crucial for making generics work with custom type aliases like type MyInt int.

Example Time! ๐Ÿ’ก

Letโ€™s combine concepts for a sum function for numeric types:

1
2
3
4
5
6
7
8
9
10
11
type Number interface {
    ~int | ~int32 | ~float64 // Allows int, int32, float64, and their aliases
}

func Sum[T Number](items ...T) T {
    var total T
    for _, item := range items {
        total += item // Valid because T is a Number
    }
    return total
}

Real-World Example: Generic Filter Function ๐Ÿ”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import "fmt"

// Predicate is a generic function type that returns bool
type Predicate[T any] func(T) bool

// Filter returns a new slice containing only elements that satisfy the predicate
func Filter[T any](items []T, predicate Predicate[T]) []T {
    result := make([]T, 0)
    for _, item := range items {
        if predicate(item) {
            result = append(result, item)
        }
    }
    return result
}

type Product struct {
    Name  string
    Price float64
}

func main() {
    // Filter integers
    numbers := []int{1, 2, 3, 4, 5, 6}
    evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
    fmt.Println("Even numbers:", evens) // Output: [2 4 6]
    
    // Filter products by price
    products := []Product{
        {Name: "Laptop", Price: 999.99},
        {Name: "Mouse", Price: 29.99},
        {Name: "Keyboard", Price: 79.99},
    }
    affordable := Filter(products, func(p Product) bool { return p.Price < 100 })
    fmt.Printf("Affordable products: %+v\n", affordable)
}

Understanding Generic Interfaces: Your Flexible Blueprints! ๐Ÿค”

Ever wished you could design a blueprint (an interface) that works with any type of data, not just one specific type? Thatโ€™s exactly what generic interfaces do!

What are Type Parameters? โœจ

Generic interfaces use type parameters, which are like placeholders for actual data types. Youโ€™ll often see them as T (for Type), E (for Element), K (for Key), or V (for Value).

For example:

1
2
3
4
5
6
// Generic container interface
type Container[T any] interface {
    Add(item T)
    Get(index int) T
    Size() int
}

Here, T isnโ€™t a real type yet. Itโ€™s a promise that when you use Container, youโ€™ll tell it what T should be, like implementing it for string or int types.

Powerful Abstractions & Reusability ๐Ÿ’ช

This enables powerful abstractions! Instead of writing separate interfaces for string containers, int containers, etc., you create one generic interface. This means:

  • Less Code Duplication: Define common behavior once.
  • Flexibility: Works with any data type you specify.
  • Type Safety: The compiler ensures youโ€™re using the correct types.

Think of how Goโ€™s standard library could benefit from interfaces like Comparable[T] or Iterator[T] for building reusable, type-safe components!

graph LR
    A["๐Ÿ“‹ Generic Interface[T]"]:::style1 --> B{"๐ŸŽฏ T can be..."}:::style2
    B --> C["๐Ÿ“ string Type"]:::style3
    B --> D["๐Ÿ”ข int Type"]:::style4
    B --> E["๐Ÿ—๏ธ Custom Struct"]:::style5

    classDef style1 fill:#ff4f81,stroke:#c43e3e,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style2 fill:#ffd700,stroke:#d99120,color:#222,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style3 fill:#6b5bff,stroke:#4a3f6b,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style4 fill:#00bfae,stroke:#005f99,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;
    classDef style5 fill:#43e97b,stroke:#38f9d7,color:#fff,font-size:16px,stroke-width:3px,rx:14,shadow:6px;

    linkStyle default stroke:#e67e22,stroke-width:3px;

Real-World Example: Generic Repository Pattern ๐Ÿ—„๏ธ

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import "fmt"

// Repository is a generic interface for data access operations
type Repository[T any] interface {
    Save(item T) error
    FindByID(id string) (T, error)
    FindAll() ([]T, error)
    Delete(id string) error
}

// MemoryRepository implements Repository using in-memory storage
type MemoryRepository[T any] struct {
    data map[string]T
}

func NewMemoryRepository[T any]() *MemoryRepository[T] {
    return &MemoryRepository[T]{
        data: make(map[string]T),
    }
}

func (r *MemoryRepository[T]) Save(id string, item T) error {
    r.data[id] = item
    return nil
}

func (r *MemoryRepository[T]) FindByID(id string) (T, error) {
    item, exists := r.data[id]
    if !exists {
        var zero T
        return zero, fmt.Errorf("item not found")
    }
    return item, nil
}

func (r *MemoryRepository[T]) FindAll() ([]T, error) {
    items := make([]T, 0, len(r.data))
    for _, item := range r.data {
        items = append(items, item)
    }
    return items, nil
}

type User struct {
    ID    string
    Name  string
    Email string
}

func main() {
    // Generic repository for users
    userRepo := NewMemoryRepository[User]()
    userRepo.Save("1", User{ID: "1", Name: "Alice", Email: "alice@example.com"})
    
    user, _ := userRepo.FindByID("1")
    fmt.Printf("Found user: %+v\n", user)
}

Generics: Flexible Code, Strong Types! โœจ

Generics let you write reusable code that works with various data types while maintaining type safety. They act as placeholders for types, catching potential errors early, at compile-time.

๐Ÿ’ก When to Use Generics

  • Data Structures: Build flexible collections like List<T> or Map<K, V> that can hold any specified type (e.g., List<String>, Map<Integer, User>), avoiding duplicate code for each type.
  • Algorithms: Create generic methods, such as a sort(List<T> items) function, applicable to lists of diverse, comparable types.

๐Ÿ”„ Generics vs. Interfaces

  • Generics (List<T>): Focus on what type an object holds. They provide type safety for collections and methods.
  • Interfaces (Comparable<T>): Focus on what an object can do (behavior contracts, polymorphism). They define capabilities.
  • Prefer Interfaces: When defining object capabilities (e.g., making objects Comparable) or enforcing specific behavior.

๐Ÿšซ Avoid Over-Generification

Donโ€™t use generics if a specific type is always known (e.g., just use List<String> if itโ€™s always strings). Unnecessary generics can add complexity and make your code harder to understand. Keep it simple when possible.

โš–๏ธ Balance Readability & Type Safety

Generics significantly enhance compile-time type safety, preventing runtime errors. While powerful, overly complex generic signatures can hinder readability. Strive for clarity: use generics when they genuinely simplify and strengthen your code, not merely for the sake of being generic.


๐ŸŽฏ Hands-On Assignment: Build a Generic Data Pipeline ๐Ÿš€

๐Ÿ“ Your Mission

Create a flexible, type-safe data pipeline using Go generics that can transform and filter data streams. You'll implement generic transformers, filters, and collectors that work with any data type, demonstrating the power of generics for building reusable components.

๐ŸŽฏ Requirements

  1. Create a generic Pipeline[T any] struct that holds a slice of data
    • Map[U any](fn func(T) U) *Pipeline[U] - Transform each element
    • Filter(fn func(T) bool) *Pipeline[T] - Keep elements matching predicate
    • Reduce[U any](initial U, fn func(U, T) U) U - Aggregate to single value
    • Collect() []T - Return final slice
  2. Implement generic constraint Numeric for types supporting arithmetic operations
  3. Create helper functions: Average[T Numeric](items []T) float64
  4. Build a generic Set[T comparable] type with Add, Contains, Union, Intersection methods
  5. Handle edge cases: empty pipelines, nil values, zero values
  6. Use type inference wherever possible for clean API

๐Ÿ’ก Implementation Hints

  1. Start with the Pipeline struct: type Pipeline[T any] struct { data []T }
  2. For Map, create a new pipeline with transformed type: Pipeline[U]
  3. Chain methods by returning *Pipeline[T] from Filter
  4. Use the tilde ~ operator for Numeric: ~int | ~int64 | ~float64
  5. For Set, use map[T]struct{} for efficient membership testing
  6. Remember comparable constraint for Set keys (needed for map keys)

๐Ÿš€ Example Input/Output

// Example usage of your generic pipeline
package main

import "fmt"

func main() {
    // Transform and filter integers
    numbers := []int{1, 2, 3, 4, 5, 6}
    result := NewPipeline(numbers).
        Filter(func(n int) bool { return n%2 == 0 }).
        Map(func(n int) int { return n * n }).
        Collect()
    fmt.Println(result) // Output: [4 16 36]
    
    // String transformation
    words := []string{"hello", "world", "go", "generics"}
    lengths := NewPipeline(words).
        Map(func(s string) int { return len(s) }).
        Collect()
    fmt.Println(lengths) // Output: [5 5 2 8]
    
    // Generic Set operations
    set1 := NewSet([]int{1, 2, 3, 4})
    set2 := NewSet([]int{3, 4, 5, 6})
    intersection := set1.Intersection(set2)
    fmt.Println(intersection.ToSlice()) // Output: [3 4]
    
    // Average calculation
    avg := Average([]float64{1.5, 2.5, 3.5, 4.5})
    fmt.Printf("Average: %.2f\n", avg) // Output: Average: 3.00
}

๐Ÿ† Bonus Challenges

  • Level 2: Add Take(n int) and Skip(n int) methods to Pipeline
  • Level 3: Implement generic GroupBy[K comparable, V any](fn func(V) K) returning map[K][]V
  • Level 4: Add Parallel() method that processes pipeline operations concurrently with goroutines
  • Level 5: Create generic Result[T any] type for error handling (like Rust's Result)
  • Level 6: Implement generic Tree[T any] with in-order, pre-order, post-order traversal methods

๐Ÿ“š Learning Goals

  • Master type parameters and constraints in Go generics ๐ŸŽฏ
  • Build reusable, type-safe data structures and algorithms โœจ
  • Understand when to use any vs comparable vs custom constraints ๐Ÿ”„
  • Practice method chaining with generic return types ๐Ÿ”—
  • Learn the tilde operator ~ for underlying type matching ๐Ÿ› ๏ธ
  • Apply generics to real-world patterns like pipelines and repositories ๐Ÿ“Š

๐Ÿ’ก Pro Tip: This pipeline pattern is used in production frameworks like Apache Beam (Go SDK), and generic data structures appear in libraries like golang.org/x/exp/slices and golang.org/x/exp/maps!

Share Your Solution! ๐Ÿ’ฌ

Completed the project? Post your code in the comments below! Show us your Go generics mastery! ๐Ÿš€โœจ


Conclusion: Embrace Type-Safe Flexibility with Go Generics ๐ŸŽ“

Go generics introduced in 1.18 revolutionize code reusability by enabling type-safe, flexible functions and data structures without code duplication. By mastering type parameters, constraints, and knowing when to use generics versus interfaces, youโ€™ll write cleaner, more maintainable Go code that scales elegantly from simple utilities to complex production systems.

This post is licensed under CC BY 4.0 by the author.