Type Parameters And Generics

Overview

Generics in Go provide a powerful way to write reusable and type-safe code. They allow developers to write functions and data structures that can work with different types, without sacrificing type safety. With generics, we can create flexible and efficient algorithms that can be used with various data types, reducing code duplication and improving code maintainability. Generics enable us to write more generic and abstract code, making our programs more flexible and adaptable to different scenarios.

Generics

Generics allow us to write functions and types that work with multiple types while maintaining compile-time type safety.

Generics were introduced in Go 1.18 to solve problems that interfaces alone couldn’t address elegantly. They provide a way to write reusable code without sacrificing performance or type safety.

Basic Generic Syntax

Before we dive into why generics are useful, let’s understand the basic syntax. Generic functions use square brackets [] to declare type parameters:

func Name[T any](parameters) (returns) {
	// ...
}

Let’s break down this syntax:

  1. Function Name: Name is the regular function name
  2. Type Parameters: [T any] declares a type parameter called T
    • T is a placeholder for the actual type that will be used
    • any is a constraint meaning T can be any type
  3. Parameters: Use T in the parameter list to refer to the generic type
  4. Returns: Can also use T in the return type

Echo Example

Here T is a type parameter that can be any type. Let’s see this in action:


func Echo[T any](input T) T {
	return input
}

Let’s understand what’s happening here:

  1. Generic Function: Echo[T any](input T) T accepts any type T
  2. Type Parameter Usage: The parameter input is of type T
  3. Type Inference: When we call Echo("Hello, World!"), Go infers that T is string
  4. Multiple Types: The same function works with strings, integers, and booleans

This function can work with any type - strings, integers, booleans, or custom types.

But you might be wondering: “Why is this better than just using interfaces?”

Why Do We Need Generics?

Great question! While interfaces are powerful for many use cases, they have limitations when we need to maintain type safety across operations. The simple example we just saw doesn’t really show the power of generics.

Let’s explore a more realistic scenario that demonstrates why generics are essential.

The Problem With Interfaces

Consider a function that returns the keys of a map. Using interfaces, we might write:


func Keys(m map[any]any) []any {
	keys := make([]any, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	return keys
}

Let’s break down what’s happening here:

  1. Function Signature: We accept a map with any keys and any values
  2. Key Extraction: We iterate through the map and collect all keys into a slice
  3. Return Type: We return []any because we don’t know the specific type of keys

While this works, it’s difficult to use because we lose type safety:


// This is cumbersome - we lose type safety
stringIntMap := map[string]int{"a": 1, "b": 2}

// We need to convert to map[any]any
anyMap := make(map[any]any)
for k, v := range stringIntMap {
	anyMap[k] = v
}

keys := Keys(anyMap)
fmt.Println("Keys:", keys)

// We need to assert types when using the results
for _, key := range keys {
	if strKey, ok := key.(string); ok {
		fmt.Println("String key:", strKey)
	}
}

Notice the problems with this approach:

  1. Type Conversion: We must convert our typed map to map[any]any before calling the function
  2. Loss of Type Information: The returned slice is []any, not []string
  3. Type Assertions: We need to assert types when using the results (key.(string))
  4. Runtime Errors: Type assertions can panic if we get the types wrong
  5. No Compile-Time Safety: The compiler can’t help us catch type-related errors

This is exactly the kind of problem generics were designed to solve - maintaining type safety while providing flexibility.

Solving With Generics

Now let’s see how generics elegantly solve this problem while maintaining type safety:


func Keys[K comparable, V any](m map[K]V) []K {
	keys := make([]K, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	return keys
}

Let’s examine the generic function signature:

  1. Type Parameters: [K comparable, V any] declares two type parameters
    • K is the key type, constrained to comparable (can be used with == and !=)
    • V is the value type, with no constraints (any)
  2. Function Parameter: m map[K]V accepts a map with keys of type K and values of type V
  3. Return Type: []K returns a slice of keys with the same type as the map’s keys
  4. Type Safety: The compiler ensures all types match throughout the function

Now we can use this function with any map type:


// Much cleaner - type safety is maintained
stringIntMap := map[string]int{"a": 1, "b": 2, "c": 3}
intStringMap := map[int]string{1: "one", 2: "two", 3: "three"}

stringKeys := Keys(stringIntMap)
intKeys := Keys(intStringMap)

fmt.Printf("String Keys\t Type: %[1]T\tKeys: %[1]v\n", stringKeys)
fmt.Printf("Int Keys\t Type: %[1]T\tKeys: %[1]v\n", intKeys)

// No type assertions needed!
for _, key := range stringKeys {
	fmt.Printf("Key: %s, Value: %d\n", key, stringIntMap[key])
}

Notice the improvements:

  1. No Type Conversion: We can pass our maps directly without converting to map[any]any
  2. Type Safety: The returned slices have the correct types ([]string and []int)
  3. No Type Assertions: We can use the keys directly without type assertions
  4. Compile-Time Checking: The compiler catches type errors at compile time
  5. Clean Code: The usage is much cleaner and more intuitive

The beauty of this approach is that we maintain complete type safety while gaining flexibility. But generics can do even more than this simple example.

Understanding The Comparable Constraint

You might have noticed we used comparable as a constraint for the map key type K. This is a special built-in constraint in Go that’s essential for working with maps and certain operations. Let’s understand what it means and why it’s important.

What is Comparable?

The comparable constraint is a predeclared identifier in Go (like int, string, any) that represents all types that can be compared using the == and != operators. This is crucial for map keys because Go needs to compare keys to determine if they already exist in the map.

Types That Satisfy Comparable:

Always Comparable:

  • All boolean types (bool)
  • All numeric types (int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, complex64, complex128)
  • All string types (string)
  • Pointer types (*T)
  • Channel types (chan T)
  • Interface types (can be compared to nil or other interface values)
  • Arrays of comparable types ([N]T where T is comparable)
  • Structs where all fields are comparable

Never Comparable:

  • Slices ([]T)
  • Maps (map[K]V)
  • Functions (func())

Why Map Keys Must Be Comparable:

Maps in Go are implemented as hash tables. When you insert or look up a value, Go needs to:

  1. Hash the key to find the bucket
  2. Compare the key with existing keys in that bucket using ==

Without the ability to compare keys, Go couldn’t determine if a key already exists in the map.

Example with Different Comparable Types:

// All these work because the key types are comparable
var intMap map[int]string              // int is comparable
var stringMap map[string]int           // string is comparable
var ptrMap map[*User]string           // pointers are comparable
var structMap map[Point]string        // structs with comparable fields are comparable

// These would cause compile errors
// var sliceMap map[[]int]string      // ❌ slices are not comparable
// var mapMap map[map[string]int]bool // ❌ maps are not comparable

When to Use Comparable vs Ordered:

  • Use comparable: When you need to check equality (==, !=) - perfect for map keys, checking if values are equal
  • Use constraints.Ordered: When you need ordering comparisons (<, <=, >=, >) - for sorting, finding min/max values

constraints.Ordered is more restrictive than comparable - all ordered types are comparable, but not all comparable types are ordered (e.g., complex numbers are comparable but not ordered).

How Go Generics Work Behind The Scenes

Important: Go generics are NOT implemented using traditional monomorphization (code generation) like C++ templates or Rust. This is a common misconception.

Instead, Go uses a hybrid approach called “GCShape stenciling with dictionaries”:

  1. GCShape Grouping: Types with the same underlying type or all pointer types share the same compiled function
  2. Dictionary Passing: Every generic function call receives a hidden dictionary parameter (passed in register AX on x86_64)
  3. Runtime Resolution: Method calls and type operations use the dictionary to resolve types at runtime

Real Example of GCShape Grouping

What you write:

func process[T int | string | *User](data T) {
    // ... some processing
}

func main() {
    process(42)           // int
    process("hello")      // string  
    process(&User{})      // *User
    process(&Product{})   // *Product
}

What the compiler actually generates:

// Shape for int (distinct from string)
func process[go.shape.int_0](dict *Dictionary, data go.shape.int_0)

// Shape for string (distinct from int)  
func process[go.shape.string_0](dict *Dictionary, data go.shape.string_0)

// Shape for ALL pointer types (*User, *Product, *int, *string, etc.)
func process[go.shape.*uint8_0](dict *Dictionary, data go.shape.*uint8_0)

The Dictionary Contains:

  • Runtime type information for each type parameter
  • Method tables (itabs) for calling methods on generic types
  • Type conversion information for interface conversions

Performance Implications:

  • Compile-time: Faster than full monomorphization
  • Runtime: Can be slower than interfaces due to dictionary lookups
  • Method calls: Require double indirection (dictionary → itab → method)

Source: DeepSource Go 1.18 Generics Implementation

Multiple Type Parameters

For more complex scenarios, we can specify multiple type parameters in a single function:


func Transform[T any, U any](input T, transformer func(T) U) U {
	return transformer(input)
}

Let’s break down this function with multiple type parameters:

  1. Type Parameters: [T any, U any] declares two separate type parameters

    • T (input type) can be any type - no constraints
    • U (output type) can be any type - no constraints
  2. Function Parameters:

    • input T - a value of type T to transform
    • transformer func(T) U - a function that converts from type T to type U
  3. Return Type: U - returns a value of type U (the transformed result)

  4. Type Relationships: The compiler ensures that the input type matches the transformer function’s input parameter, and the return type matches the transformer function’s return type

This allows us to work with different input and output types:


// Transform string to int
result1 := Transform("hello", func(s string) int {
	return len(s)
})
fmt.Println("String length:", result1)

// Transform int to string
result2 := Transform(42, func(i int) string {
	return fmt.Sprintf("Number: %d", i)
})
fmt.Println("Formatted number:", result2)

// Transform int to bool
result3 := Transform(10, func(i int) bool {
	return i%2 == 0
})
fmt.Println("Is even:", result3)

Notice how the function works seamlessly with different type transformations:

  1. First Call: Transform("hello", func(s string) int { return len(s) }) - transforms string to int (length)
  2. Second Call: Transform(42, func(i int) string { return fmt.Sprintf("Number: %d", i) }) - transforms int to string (formatting)
  3. Third Call: Transform(10, func(i int) bool { return i%2 == 0 }) - transforms int to bool (even check)
  4. Type Safety: Each call returns the correct output type without any type assertions
  5. Flexibility: One function handles multiple input-output type combinations

So far we’ve been using any as our constraint, which accepts any type. But what if we want to be more specific about which types are allowed?

Defining Custom Constraints

We can create custom constraints to be more specific about which types are allowed:


type Numeric interface {
	int | int32 | int64 | float32 | float64
}

func Add[T Numeric](a, b T) T {
	return a + b
}

func Multiply[T Numeric](a, b T) T {
	return a * b
}

Union Type Constraints

We can use the | operator to create union constraints that allow multiple types:


type StringOrInt interface {
	string | int
}

process := func[T StringOrInt](input T) {
	switch v := any(input).(type) {
	case string:
		fmt.Printf("Processing string: %s (length: %d)\n", v, len(v))
	case int:
		fmt.Printf("Processing int: %d (doubled: %d)\n", v, v*2)
	}
}

process("hello")
process(42)

Type Assertions With Union Constraints

When working with union constraints, you might need to handle each type differently within your generic function. Notice in the example above how we use a type switch to determine which specific type we’re working with:

switch v := any(input).(type) {
case string:
    // Handle string specifically
case int:
    // Handle int specifically
}

Why Type Assertions with Generics?

  1. Type-Specific Operations: When different types in your constraint need different handling

    • Strings might need length checks
    • Numbers might need arithmetic operations
    • Custom types might need method calls
  2. Optimization: You can provide optimized code paths for specific types

    • Fast path for built-in types
    • Special handling for custom types
  3. Behavior Customization: Different types can have completely different behaviors

    • Formatting output differently based on type
    • Validation rules that differ by type

Important Considerations:

  • The Conversion: any(input).(type) converts the generic type to any so we can use a type switch
  • Type Safety Still Applies: The type switch can only match types allowed by the constraint
  • Manual Coverage Required: You must explicitly handle all types in the constraint or include a default case - Go does not perform exhaustiveness checking on type switches
  • When Not to Use: If all types can be handled the same way, avoid type switching - it defeats the purpose of generics

Alternative Pattern - Using the Type Directly:

For operations that work across all types in your constraint, you don’t need type assertions:

func Double[T StringOrInt](input T) string {
    // This works because both string and int can be formatted
    return fmt.Sprintf("%v%v", input, input)
}

Type Safety With Constraints

One of the key benefits of generics with constraints is that the compiler enforces type safety at compile time. This prevents runtime errors that could occur with empty interfaces.

Given this function:


type Number interface {
	int | float64
}

func Add[T Number](a, b T) T {
	return a + b
}

Let’s see what happens when we try to use a type that doesn’t satisfy our constraint:


func main() {
	// This works fine - int satisfies Number constraint
	result1 := Add(5, 3)
	fmt.Println("5 + 3 =", result1)

	// This works fine - float64 satisfies Number constraint
	result2 := Add(2.5, 1.3)
	fmt.Println("2.5 + 1.3 =", result2)

	// This will cause a compile error - string doesn't satisfy Number constraint
	result3 := Add("hello", "world")
	fmt.Println("Concatenation:", result3)
}

Here’s what’s happening in this example:

  1. Valid Constraint: StringType satisfies the ~string constraint because its underlying type is string
  2. Invalid Constraint: IntType does not satisfy the ~string constraint because its underlying type is int, not string
  3. Function Call: We try to call processString with both types

The compiler will catch this error and prevent the code from compiling:


// ./main.go:23:19: string does not satisfy Number (string not in int | float64)

Key benefits of compile-time constraint checking:

  1. Early Detection: Errors are caught at compile time, not runtime
  2. Clear Error Messages: The compiler tells us exactly what’s wrong and why
  3. No Runtime Panics: Unlike with empty interfaces, invalid types can’t cause runtime crashes
  4. Better IDE Support: IDEs can provide better autocompletion and error highlighting
  5. Confidence: You know if your code compiles, the type constraints are satisfied

This is much better than runtime panics you might get with empty interfaces, where errors only surface when the code is executed and could crash your running application.

Underlying Type Constraints

The ~ operator allows us to accept types based on a specific underlying type:


type UserID int
type ProductID int
type Score float64

Here we define custom types that have int and float64 as their underlying types. Without generics, we’d need separate functions for each type.


type IntegerID interface {
	~int
}

type FloatScore interface {
	~float64
}

Let’s understand what these constraints do:

  1. ~int Constraint: The ~ (tilde) operator means “any type whose underlying type is int

    • This includes int itself, but also custom types like UserID and ProductID
    • Without ~, the constraint would only accept exactly int, not custom types based on int
  2. ~float64 Constraint: Similarly, this accepts any type whose underlying type is float64

    • This includes float64 itself and custom types like Score

processID := func[T IntegerID](id T) {
	fmt.Printf("Processing ID: %v\n", id)
}

processScore := func[T FloatScore](score T) {
	fmt.Printf("Processing score: %.2f\n", score)
}

var userID UserID = 123
var productID ProductID = 456
var score Score = 95.5

processID(userID)
processID(productID)
processScore(score)

Notice what happens in the usage:

  1. Type Flexibility: Both UserID and ProductID can be used with processID because they both have int as their underlying type
  2. Type Safety: We still maintain type safety - we can’t accidentally pass a UserID where a ProductID is expected in our business logic
  3. Code Reuse: One generic function works with multiple related types instead of needing separate functions for each type

This is particularly useful when you have domain-specific types that share common operations but need to remain distinct types in your API.

The Constraints Package

Go provides a golang.org/x/exp/constraints package with predefined constraints for common use cases:


"golang.org/x/exp/constraints"

// Using constraints.Signed for signed integers
func max[T constraints.Signed](a, b T) T {
	if a > b {
		return a
	}
	return b
}

// Using constraints.Unsigned for unsigned integers
func min[T constraints.Unsigned](a, b T) T {
	if a < b {
		return a
	}
	return b
}

// Using constraints.Float for floating point numbers
func average[T constraints.Float](numbers ...T) T {
	var sum T
	for _, num := range numbers {
		sum += num
	}
	return sum / T(len(numbers))
}

	fmt.Println("Max of -5 and 10:", max(-5, 10))
	fmt.Println("Min of 3 and 7:", min(uint(3), uint(7)))
	fmt.Println("Average of 1.5, 2.5, 3.5:", average(1.5, 2.5, 3.5))

Common Predefined Constraints

The constraints package includes several useful predefined constraints:

// Signed integer types
constraints.Signed

// Unsigned integer types  
constraints.Unsigned

// All integer types
constraints.Integer

// All floating point types
constraints.Float

// All ordered types (comparable)
constraints.Ordered

Ordered Constraint

The Ordered constraint is particularly useful for sorting and comparison operations. It allows us to write generic functions that work with any type that supports comparison operators (<, <=, >=, >).

What is Ordered?

The Ordered constraint is defined in the constraints package:

// Ordered is a constraint that permits any ordered type: any type
// that supports the operators < <= >= >.
// If future releases of Go add new ordered types,
// this constraint will be modified to include them.
type Ordered interface {
	Integer | Float | ~string
}

This constraint includes:

  • All integer types (int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64)
  • All floating-point types (float32, float64)
  • String types (string and custom types with string as underlying type)

Generic Functions With Ordered

Let’s see how we can use this constraint to create generic sorting and comparison functions:


func Sort[T constraints.Ordered](slice []T) []T {
	// Simple bubble sort for demonstration
	result := make([]T, len(slice))
	copy(result, slice)

	for i := 0; i < len(result); i++ {
		for j := 0; j < len(result)-1-i; j++ {
			if result[j] > result[j+1] {
				result[j], result[j+1] = result[j+1], result[j]
			}
		}
	}
	return result
}

func Min[T constraints.Ordered](a, b T) T {
	if a < b {
		return a
	}
	return b
}

Key benefits of these generic functions:

  1. Sort Function: Uses bubble sort algorithm but works with any ordered type

    • The T constraints.Ordered parameter ensures T can be compared with > operator
    • Creates a copy of the input slice to avoid modifying the original
    • Uses the same sorting logic regardless of the specific type
  2. Min Function: Finds the minimum of two values of any ordered type

    • The comparison a < b works because T is constrained to Ordered
    • Returns the same type that was passed in, maintaining type safety

Using Ordered Functions

Here’s how these generic functions work with different ordered types:


ints := []int{3, 1, 4, 1, 5, 9, 2, 6}
strings := []string{"banana", "apple", "cherry", "date"}
floats := []float64{3.14, 2.71, 1.41, 1.73}

fmt.Println("Original ints:", ints)
fmt.Println("Sorted ints:", Sort(ints))

fmt.Println("Original strings:", strings)
fmt.Println("Sorted strings:", Sort(strings))

fmt.Println("Original floats:", floats)
fmt.Println("Sorted floats:", Sort(floats))

fmt.Println("Min of 10 and 5:", Min(10, 5))
fmt.Println("Min of 'apple' and 'banana':", Min("apple", "banana"))

Notice what happens in this example:

  1. Type Flexibility: The same Sort function works with []int, []string, and []float64
  2. Type Safety: Each function call returns the same type that was passed in
  3. No Type Conversion: We don’t need to convert types or write separate functions for each type
  4. Efficient: No runtime type checking or assertions - all type safety is verified at compile time

This demonstrates the power of the Ordered constraint for creating reusable algorithms that work across multiple numeric and string types while maintaining full type safety.

Exercise (20 Mins)

Create a generic Filter function that takes a slice and a predicate function, returning a new slice with only the elements that satisfy the predicate.

The function should work with any type and look like this:

func Filter[T any](slice []T, predicate func(T) bool) []T

Test it with:

  • A slice of integers, filtering for even numbers
  • A slice of strings, filtering for strings longer than 3 characters

package main

import "fmt"

// TODO: Implement the Filter function using generics

func main() {
	// Test with integers - filter even numbers
	numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	evens := Filter(numbers, func(n int) bool {
		return n%2 == 0
	})
	fmt.Println("Even numbers:", evens)

	// Test with strings - filter strings longer than 3 characters
	words := []string{"go", "rust", "python", "c", "javascript", "lua"}
	longWords := Filter(words, func(s string) bool {
		return len(s) > 3
	})
	fmt.Println("Long words:", longWords)

	// Test with floats - filter numbers greater than 5.0
	floats := []float64{1.1, 3.3, 5.5, 7.7, 2.2, 9.9}
	bigFloats := Filter(floats, func(f float64) bool {
		return f > 5.0
	})
	fmt.Println("Big floats:", bigFloats)
}

Exercise Solution

The solution demonstrates key generic programming concepts:

  1. Generic Function Declaration: Filter[T any] accepts any type
  2. Type Parameter Usage: Uses T in both parameter and return types
  3. Type Safety: Maintains type throughout the function
  4. Flexibility: Works with any type while preserving type information

package main

import "fmt"

// Filter takes a slice and a predicate function, returning a new slice
// with only the elements that satisfy the predicate
func Filter[T any](slice []T, predicate func(T) bool) []T {
	var result []T
	for _, item := range slice {
		if predicate(item) {
			result = append(result, item)
		}
	}
	return result
}

func main() {
	// Test with integers - filter even numbers
	numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	evens := Filter(numbers, func(n int) bool {
		return n%2 == 0
	})
	fmt.Println("Even numbers:", evens)

	// Test with strings - filter strings longer than 3 characters
	words := []string{"go", "rust", "python", "c", "javascript", "lua"}
	longWords := Filter(words, func(s string) bool {
		return len(s) > 3
	})
	fmt.Println("Long words:", longWords)

	// Test with floats - filter numbers greater than 5.0
	floats := []float64{1.1, 3.3, 5.5, 7.7, 2.2, 9.9}
	bigFloats := Filter(floats, func(f float64) bool {
		return f > 5.0
	})
	fmt.Println("Big floats:", bigFloats)
}

Key Learning Points:

  • How to declare generic functions with type parameters
  • Using any constraint for maximum flexibility
  • Maintaining type safety across function boundaries
  • Working with higher-order functions (predicates) in generic contexts

Generic Data Structures

We can also create generic types, not just functions:


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

func NewStack[T any]() *Stack[T] {
	return &Stack[T]{
		items: make([]T, 0),
	}
}

Let’s break down the generic type definition:

  1. Generic Type Declaration: Stack[T any] defines a generic type with type parameter T
  2. Type Parameter Usage: The items field uses []T, meaning it’s a slice of whatever type T represents
  3. Constructor Function: NewStack[T any]() creates a new stack instance
  4. Memory Allocation: We initialize the slice with make([]T, 0) for efficient memory usage

func (s *Stack[T]) Push(item T) {
	s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() (T, bool) {
	if len(s.items) == 0 {
		var zero T
		return zero, false
	}

	index := len(s.items) - 1
	item := s.items[index]
	clear(s.items[index:])
	s.items = s.items[:index]
	return item, true
}

func (s *Stack[T]) Peek() (T, bool) {
	if len(s.items) == 0 {
		var zero T
		return zero, false
	}
	return s.items[len(s.items)-1], true
}

func (s *Stack[T]) Size() int {
	return len(s.items)
}

func (s *Stack[T]) IsEmpty() bool {
	return len(s.items) == 0
}

Now let’s examine the methods:

  1. Push Method: Push(item T) accepts an item of type T and adds it to the stack
  2. Pop Method: Returns (T, bool) - the item and whether the operation was successful
    • Uses var zero T to create a zero value of type T when the stack is empty
    • Safely removes the last item using slice operations
  3. Peek Method: Returns the top item without removing it
  4. Utility Methods: Size() and IsEmpty() provide convenient stack information

The key advantage is that all these methods work with the specific type T that was chosen when creating the stack instance, providing full type safety.

Using Generic Types


// Stack of integers
intStack := NewStack[int]()
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)

fmt.Println("Int stack size:", intStack.Size())

if value, ok := intStack.Pop(); ok {
	fmt.Println("Popped from int stack:", value)
}

if value, ok := intStack.Peek(); ok {
	fmt.Println("Peeked at int stack:", value)
}

// Stack of strings
stringStack := NewStack[string]()
stringStack.Push("hello")
stringStack.Push("world")

fmt.Println("String stack size:", stringStack.Size())

for !stringStack.IsEmpty() {
	if value, ok := stringStack.Pop(); ok {
		fmt.Println("Popped from string stack:", value)
	}
}

Let’s examine how we use the generic stack:

  1. Type Instantiation:

    • NewStack[int]() creates a stack specifically for integers
    • NewStack[string]() creates a stack specifically for strings
    • The type parameter [int] and [string] determine what type the stack will store
  2. Type Safety:

    • The integer stack can only store integers
    • The string stack can only store strings
    • Trying to push a string onto the integer stack would be a compile-time error
  3. Method Usage:

    • Push() adds items to the stack
    • Pop() returns (value, ok) where ok indicates success
    • Peek() lets us see the top item without removing it
    • Size() and IsEmpty() provide stack state information
  4. Benefits:

    • No Type Casting: We get back the exact type we put in
    • Compile-Time Safety: The compiler ensures type correctness
    • Performance: No runtime type assertions or boxing/unboxing
    • Code Reuse: One stack implementation works for any type

Generic Type Aliases

Go 1.24 introduced full support for generic type aliases. Type aliases with type parameters allow you to create convenient shorthand names for generic types or specific instantiations of generic types.

Simple Type Aliases

The simplest form of generic type alias creates a convenient name for a specific instantiation of a generic type:


// Simple type alias - creates a convenient name for a specific instantiation
type IntStack = Stack[int]
type StringStack = Stack[string]

This is useful when you frequently use a generic type with specific type parameters. Instead of writing Stack[int] everywhere, you can use IntStack.

Parameterized Type Aliases

More powerful is the ability to create aliases that themselves take type parameters:


// Parameterized type alias - the alias itself takes type parameters
type StringMap[V any] = map[string]V
type Pair[T, U any] = struct {
	First  T
	Second U
}
type Result[T any] = struct {
	Value T
	Err   error
}

Let’s understand each alias:

  1. StringMap[V any]: Creates a map type with string keys and any value type

    • Usage: StringMap[int] becomes map[string]int
  2. Pair[T, U any]: Creates a generic pair type

    • Usage: Pair[string, int] becomes a struct with First string and Second int
  3. Result[T any]: Creates a result wrapper for any type

    • Common pattern for operations that may fail

Restrictions On Type Aliases

There’s an important restriction: the alias cannot be a type parameter itself:


// Invalid: alias cannot be a type parameter itself
// type Identity[T any] = T  // compile error: cannot use a type parameter as RHS in type declaration

This means you can’t create an “identity” type alias that just passes through the type parameter.

Using Generic Type Aliases

Here’s how to use both simple and parameterized type aliases:


func main() {
	// Using simple type aliases
	intStack := new(IntStack)
	intStack.Push(1)
	intStack.Push(2)
	intStack.Push(3)

	if val, ok := intStack.Pop(); ok {
		fmt.Println("Popped from IntStack:", val)
	}

	// Using parameterized type aliases
	ages := StringMap[int]{
		"Alice": 30,
		"Bob":   25,
	}
	fmt.Println("Ages:", ages)

	pair := Pair[string, int]{
		First:  "count",
		Second: 42,
	}
	fmt.Println("Pair:", pair)

	result := Result[string]{
		Value: "success",
		Err:   nil,
	}
	fmt.Println("Result:", result.Value)
}

Key points about generic type aliases:

  1. Instantiation Required: Parameterized type aliases must be instantiated when used (e.g., StringMap[int], not just StringMap)
  2. Type Equivalence: An alias is exactly equivalent to its underlying type - IntStack is the same type as Stack[int]
  3. No New Methods: You cannot add methods to a type alias; use a type definition instead if you need to add methods
  4. Documentation: Aliases can make code more readable by giving meaningful names to complex type expressions

Type Alias Vs Type Definition

When should you use a type alias versus a type definition?

Use a Type Alias (=) when:

  • You want a shorthand name for a complex type
  • You want interchangeability with the original type
  • You’re creating API compatibility shims
  • You don’t need to add new methods
// Type alias - IntStack and Stack[int] are the SAME type
type IntStack = Stack[int]

Use a Type Definition (no =) when:

  • You want a distinct new type
  • You need to add methods to the type
  • You want compile-time type safety between similar types
  • You’re creating domain-specific types
// Type definition - UserID is a DIFFERENT type from int
type UserID int
func (id UserID) String() string { return fmt.Sprintf("user-%d", id) }

Interface With Type Parameters

We can combine interfaces with type parameters for more advanced patterns. This approach is particularly useful for building generic data access layers or repositories that can work with different types of entities while maintaining type safety.

Let’s start by defining a generic interface that represents any entity that can be identified by an ID:


type Identifiable[T comparable] interface {
	ID() T
}

type Repository[T comparable, U Identifiable[T]] interface {
	Save(item U) error
	FindByID(id T) (U, bool)
	Delete(id T) error
}

Let’s break down these interface definitions:

  1. Identifiable[T comparable]: This interface represents any type that has an ID() method returning a value of type T

    • The type parameter T is constrained to comparable so it can be used as a map key
    • This allows entities to have different ID types (int, string, uuid.UUID, time.Time, etc.) while maintaining type safety
  2. Repository[T comparable, U Identifiable[T]]: This interface defines a generic repository pattern

    • T represents the type of the ID (must be comparable for map operations)
    • U represents the entity type, which must satisfy Identifiable[T]
    • This ensures that whatever entity type we store, its ID type matches the repository’s ID type

Now let’s create a concrete type that satisfies the Identifiable interface:


type User struct {
	id   int
	name string
}

func (u User) ID() int {
	return u.id
}

func (u User) String() string {
	return fmt.Sprintf("User{id: %d, name: %s}", u.id, u.name)
}

The User struct satisfies the Identifiable[int] interface because it implements the ID() method that returns an int. This allows the type system to verify that a User can be stored in a repository that expects entities with integer IDs.

Finally, let’s implement the generic repository interface:



type InMemoryRepository[T comparable, U Identifiable[T]] struct {
	items map[T]U
}

func NewInMemoryRepository[T comparable, U Identifiable[T]]() *InMemoryRepository[T, U] {
	return &InMemoryRepository[T, U]{
		items: make(map[T]U),
	}
}

func (r *InMemoryRepository[T, U]) Save(item U) error {
	r.items[item.ID()] = item
	return nil
}

func (r *InMemoryRepository[T, U]) FindByID(id T) (U, bool) {
	item, exists := r.items[id]
	return item, exists
}

func (r *InMemoryRepository[T, U]) Delete(id T) error {
	delete(r.items, id)
	return nil
}

This InMemoryRepository implementation demonstrates several key concepts:

  1. Generic Implementation: The repository works with any ID type T and any entity type U that satisfies Identifiable[T]
  2. Type Safety: The compiler ensures that the ID type of the entity matches the repository’s ID type
  3. Flexibility: The same repository implementation can work with User entities with int IDs, Product entities with string IDs, etc.
  4. Interface Compliance: The repository satisfies the Repository[T, U] interface contract

Note: This repository implementation is just an example and is not concurrent-safe. In a real application, you would add synchronization using a mutex to make it safe for concurrent access, which we cover in other chapters.

This pattern is particularly powerful because it allows you to write generic, reusable data access code while maintaining strict type safety and eliminating the need for type assertions.

Mixing Method And Type Constraints

So far we’ve seen two different kinds of constraints:

  1. Type Constraints: Specify which types are allowed using | (like int | string | float64)
  2. Method Constraints: Specify which methods a type must implement (like interfaces)

The powerful feature of Go generics is that you can combine both in a single constraint. This allows you to restrict your generic function to specific types and require those types to implement certain methods.

Combining Type And Method Requirements

Let’s say we want to create a constraint for values that can be both ordered (for comparison) and printed with a custom format. We can define a constraint that includes both type restrictions and method requirements:


// Printable is a constraint that combines both type and method requirements
type Printable interface {
	~int | ~string | ~float64 // Type constraint: must be based on one of these types
	String() string            // Method constraint: must implement String() method
}

Let’s break down what this constraint means:

  1. Type Union: ~int | ~string | ~float64 specifies that the type must be based on int, string, or float64
  2. Method Requirement: String() string requires that the type implements this method
  3. Combined Effect: A type must satisfy BOTH requirements - it must be one of the specified types AND implement the String() method

This is different from trying to use constraints.Ordered | fmt.Stringer, which would be a compilation error. Instead, we define the type union and methods together within the interface.

Using Mixed Constraints

Now let’s create custom types that satisfy our Printable constraint:


// Score is based on int and implements String()
type Score int

func (s Score) String() string {
	return fmt.Sprintf("Score: %d", s)
}

// Temperature is based on float64 and implements String()
type Temperature float64

func (t Temperature) String() string {
	return fmt.Sprintf("%.1f°C", t)
}

// Status is based on string and implements String()
type Status string

func (s Status) String() string {
	return fmt.Sprintf("Status: %s", string(s))
}

Notice what makes these types compatible with the Printable constraint:

  1. Score: Based on int (satisfies ~int) and implements String() method
  2. Temperature: Based on float64 (satisfies ~float64) and implements String() method
  3. Status: Based on string (satisfies ~string) and implements String() method

Now we can write a generic function that uses our mixed constraint:


// Display is a generic function that accepts any Printable type
func Display[T Printable](value T) {
	// We can call String() because the Printable constraint requires it
	fmt.Println(value.String())
}

// Compare compares two Printable values
// Note: This works because all types in Printable constraint support comparison
func Compare[T Printable](a, b T) string {
	// We can use comparison operators because underlying types are comparable
	if a == b {
		return fmt.Sprintf("%s equals %s", a.String(), b.String())
	}
	return fmt.Sprintf("%s does not equal %s", a.String(), b.String())
}

And use it with our custom types:


func main() {
	// Create instances of our custom types
	score1 := Score(95)
	score2 := Score(87)
	temp := Temperature(23.5)
	status := Status("active")

	// Display using the generic Display function
	fmt.Println("=== Display Examples ===")
	Display(score1)
	Display(temp)
	Display(status)

	// Compare values
	fmt.Println("\n=== Comparison Examples ===")
	fmt.Println(Compare(score1, score2))
	fmt.Println(Compare(score1, Score(95)))

	// We can also use the values in generic collections
	fmt.Println("\n=== Collection Example ===")
	scores := []Score{Score(85), Score(92), Score(78)}
	fmt.Println("All scores:")
	for _, s := range scores {
		Display(s)
	}
}

Benefits Of Mixed Constraints

This pattern provides several important advantages:

  1. Type Safety: The compiler ensures types meet both the type and method requirements
  2. Controlled Flexibility: You can accept multiple types while ensuring consistent behavior
  3. Method Guarantees: You know certain methods are available without type assertions
  4. Domain Modeling: Perfect for domain-specific types that share operations but need distinct types
  5. Compile-Time Checking: All constraint violations are caught at compile time, not runtime

Common Use Cases:

  • Numeric types with custom formatting (scores, temperatures, currencies)
  • Identifiable entities with specific ID types (User, Product, Order)
  • Ordered types with custom string representations
  • Domain-specific types that need both arithmetic and display operations

Type Instantiation

Type instantiation is when we explicitly specify the type parameters for a generic function or type. While Go’s compiler is quite good at inferring types from context, there are situations where we need to be explicit.

Basic Concept

Sometimes you need to tell the compiler exactly which types you want:


// Max returns the maximum of two values
func Max[T constraints.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	// Type inference works here - Go knows we want Max[int]
	result1 := Max(5, 8)
	fmt.Printf("Max(5, 8) = %d\n", result1)

	// Type instantiation - we explicitly specify the type
	result2 := Max[int](5, 8)
	fmt.Printf("Max[int](5, 8) = %d\n", result2)

	// Both calls do the same thing, but the second is explicit
}

When Type Instantiation Is Required

Function Variables

When assigning a generic function to a variable, explicit instantiation is required:


// Max returns the maximum of two values
func Max[T constraints.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	// When assigning a generic function to a variable,
	// we need explicit type instantiation

	// This would NOT compile:
	// var maxFunc = Max  // compiler error: cannot infer types

	// This works - explicit instantiation:
	var maxInt func(int, int) int = Max[int]
	var maxString func(string, string) string = Max[string]

	// Now we can use the function variables
	fmt.Printf("Max integers: %d\n", maxInt(5, 8))
	fmt.Printf("Max strings: %s\n", maxString("apple", "banana"))

	// We can also use shorter syntax:
	maxFloat := Max[float64]
	fmt.Printf("Max floats: %.2f\n", maxFloat(3.14, 2.71))
}

Multiple Type Parameters

When a function has multiple type parameters, the compiler often can’t infer all of them:


// Pair represents a pair of values
type Pair[T, U any] struct {
	First  T
	Second U
}

// NewPair creates a new pair
func NewPair[T, U any](first T, second U) Pair[T, U] {
	return Pair[T, U]{First: first, Second: second}
}

// Transform applies a function to convert first to second type
func Transform[T, U any](value T, converter func(T) U) U {
	return converter(value)
}

func main() {
	// When a function has multiple type parameters,
	// the compiler often can't infer all of them

	// This works - compiler can infer both T and U from arguments:
	pair1 := NewPair("hello", 42)
	fmt.Printf("Inferred pair: %+v\n", pair1)

	// But sometimes we need to be explicit:
	pair2 := NewPair[string, int]("world", 100)
	fmt.Printf("Explicit pair: %+v\n", pair2)

	// Transform function needs explicit instantiation
	// because the return type U can't be inferred from the first argument
	result := Transform[int, string](42, func(i int) string {
		return fmt.Sprintf("Number: %d", i)
	})
	fmt.Printf("Transformed: %s\n", result)
}

Ambiguous Context

Sometimes the context doesn’t provide enough information for type inference:


// Convert converts a numeric value to a string
func Convert[T constraints.Ordered](value T) string {
	return fmt.Sprintf("%v", value)
}

// Zero returns the zero value for any type
func Zero[T any]() T {
	var zero T
	return zero
}

func main() {
	// Sometimes the context doesn't provide enough information
	// for the compiler to infer the type

	// This works - type is inferred from the argument:
	result1 := Convert(42)
	fmt.Printf("Convert(42): %s\n", result1)

	// But when creating a function variable, we need explicit types:
	converter := Convert[int] // Must specify T = int
	result2 := converter(123)
	fmt.Printf("converter(123): %s\n", result2)

	// Zero function always needs explicit instantiation
	// because there's no argument to infer from:
	zeroInt := Zero[int]()
	zeroString := Zero[string]()

	fmt.Printf("Zero int: %d\n", zeroInt)
	fmt.Printf("Zero string: '%s'\n", zeroString)

	// This would NOT compile:
	// unknown := Zero()  // compiler error: cannot infer T
}

Zero Values Of Generic Types

When creating zero values of generic types, explicit instantiation is required:


// Stack is a generic stack data structure
type Stack[T any] struct {
	items []T
}

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

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

// Pop removes and returns the top item
func (s *Stack[T]) Pop() (T, bool) {
	if len(s.items) == 0 {
		var zero T
		return zero, false
	}
	idx := len(s.items) - 1
	item := s.items[idx]
	clear(s.items[idx:])
	s.items = s.items[:idx]
	return item, true
}

func main() {
	// When creating zero values of generic types,
	// we need explicit type instantiation

	// Creating zero values requires explicit types:
	var intStack Stack[int]       // Zero value of Stack[int]
	var stringStack Stack[string] // Zero value of Stack[string]

	fmt.Printf("Zero int stack: %+v\n", intStack)
	fmt.Printf("Zero string stack: %+v\n", stringStack)

	// Using constructor functions:
	numberStack := NewStack[int]()
	wordStack := NewStack[string]()

	// Use the stacks:
	numberStack.Push(42)
	wordStack.Push("hello")

	num, ok := numberStack.Pop()
	fmt.Printf("Popped number: %d (ok: %t)\n", num, ok)

	word, ok := wordStack.Pop()
	fmt.Printf("Popped word: %s (ok: %t)\n", word, ok)
}

Best Practices And Common Pitfalls

Generics are a powerful tool, but like any feature, they can be misused. Let’s explore when to use them, when to avoid them, and common pitfalls to watch out for.

When To Use Generics

Good Use Cases:

  1. Data Structures: Generic containers like stacks, queues, trees, graphs

    type Stack[T any] struct { items []T }
    
    1. Algorithms: Functions that work with multiple types using the same logic go func Map[T, U any](slice []T, fn func(T) U) []U func Filter[T any](slice []T, predicate func(T) bool) []T
  2. Type-Safe Wrappers: Generic result types, option types, or error handling

    type Result[T any] struct { Value T; Err error }
    
    1. Multiple Type Operations: Functions that need to maintain relationships between types go func Keys[K comparable, V any](m map[K]V) []K

When NOT To Use Generics

Avoid Generics When:

  1. Simple Interface Suffices: If io.Reader, fmt.Stringer, or other interfaces work, use them “`go // ❌ Overcomplicated func PrintT any { fmt.Println(v) }

// ✅ Better - just use any or interface func Print(v any) { fmt.Println(v) }


2. **Method-Based Behavior**: When you need behavior based on methods, not types
   ```go
   // ❌ Wrong approach - generics don't help here
   func Process[T any](items []T) { /* needs methods on T */ }

   // ✅ Better - use interface
   type Processor interface { Process() }
   func Process(items []Processor) { /* can call methods */ }
  1. Single Type: If your function only ever works with one type, don’t make it generic “`go // ❌ Unnecessary complexity func AddIntsT int T { return a + b }

// ✅ Just use the type directly func AddInts(a, b int) int { return a + b } “`

  1. Performance Critical Code: Generic code can be slightly slower due to dictionary passing
    • For tight loops or hot paths, concrete types may be faster
    • Profile before optimizing - the difference is usually negligible

Common Pitfalls

1. Over-Constraining

// ❌ Too specific - limits flexibility
func Process[T constraints.Integer](values []T) []T

// ✅ Only constrain what you actually need
func Process[T any](values []T) []T  // If you don't need arithmetic

2. Forgetting Type Instantiation

// ❌ This won't compile
var fn = Filter  // Can't infer types

// ✅ Explicitly instantiate
var fn = Filter[int]

3. Confusing any with Empty Interface Behavior

// Generic 'any' is NOT the same as interface{}
func Print[T any](v T) {
    // ❌ Can't call methods on T without constraints
    // v.String()  // Won't compile
}

4. Mixing Generic and Non-Generic Code

// ❌ Can't have generic methods (only functions and types)
func (s *Service) Process[T any](data T) {}  // Won't compile

// ✅ Make the type generic instead
type Service[T any] struct {}
func (s *Service[T]) Process(data T) {}

Note: Go 1.27 (expected August 2026) is planned to allow type parameters on methods. Until that ships, making the whole type generic, as shown here, remains the pattern to use.

5. Unnecessary Type Switching

// ❌ Defeats the purpose of generics
func Process[T any](v T) {
    switch x := any(v).(type) {
    case int: // ...
    case string: // ...
    }
}

// ✅ If you need different behavior, use constraints or interfaces

Debugging Tips

Understanding Compiler Errors:

Generic error messages can be verbose. Common patterns:

does not satisfy constraint X

→ Your type doesn’t meet the constraint requirements. Check if:

  • Type is in the union (int | string | float64)
  • Type implements required methods
  • Type satisfies built-in constraints (comparable, ordered)
cannot infer T

→ Compiler can’t determine the type. Explicitly instantiate:

result := MyFunc[int](value)  // Specify type explicitly

Testing Generic Code:

  • Test with multiple types from your constraint
  • Test with edge cases (empty values, zero values)
  • Test type inference works as expected
  • Consider table-driven tests with different types
func TestFilter(t *testing.T) {
    // Test with int
    intResult := Filter([]int{1,2,3}, func(i int) bool { return i > 1 })

    // Test with string
    strResult := Filter([]string{"a","b"}, func(s string) bool { return len(s) > 0 })
}

Performance Considerations

What You Should Know:

  1. GCShape Stenciling: Go groups similar types together

    • All pointer types share one implementation
    • Each primitive type gets its own implementation
    • Slight overhead from dictionary passing
  2. When It Matters:

    • Very tight loops (millions of iterations)
    • Hot paths in performance-critical applications
    • Embedded systems with strict performance requirements
  3. When It Doesn’t Matter:

    • Most application code
    • I/O-bound operations
    • User-facing features

Rule of Thumb: Write generic code for clarity and type safety. Only optimize to concrete types if profiling shows it’s a bottleneck.

Quick Reference: Best Practices

  1. Start simple: Use any when you don’t need specific constraints
  2. Be specific: Use constraints to limit types when appropriate
  3. Use predefined constraints: Leverage golang.org/x/exp/constraints when possible
  4. Prefer constraints over type switching: Let the type system do the work
  5. Test with multiple types: Ensure your generic code works across all intended types
  6. Consider readability: Generic code should still be easy to understand
  7. Profile before optimizing: Don’t avoid generics for performance without evidence
  8. Don’t over-generalize: If simple interfaces work, use them
  9. Don’t use for everything: Generics are a tool, not a requirement
  10. Don’t sacrifice clarity: If generics make code harder to understand, reconsider

Exercise (25 Minues)

Objective: Implement a generic Queue (FIFO - First In, First Out) data structure.

Requirements:

  • Generic type: Queue[T any]
  • Constructor: NewQueue[T any]() *Queue[T]
  • Methods:
    • Enqueue(item T) - add item to back
    • Dequeue() (T, bool) - remove item from front
    • Peek() (T, bool) - view front item without removing
    • Size() int - return number of items
    • IsEmpty() bool - check if queue is empty

Key Difference from Stack: Queue removes from front (FIFO), Stack removes from back (LIFO)


package main

import "fmt"

// TODO: Implement a generic Queue data structure
//
// Queue should be a generic type that can work with any type T
//
// TODO: Implement a constructor function
//
// TODO: Implement the following methods:
//
// TODO: Implement the following methods:
// Enqueue adds an item to the back of the queue
//
// Dequeue removes and returns the item from the front of the queue
// Returns the item and a boolean indicating success
//
// Peek returns the front item without removing it
// Returns the item and a boolean indicating success
//
// Size returns the number of items in the queue
//
// IsEmpty returns true if the queue is empty

func main() {
	// Test with integers
	fmt.Println("=== Testing Integer Queue ===")
	intQueue := NewQueue[int]()

	// Add some numbers
	intQueue.Enqueue(10)
	intQueue.Enqueue(20)
	intQueue.Enqueue(30)

	fmt.Printf("Queue size: %d\n", intQueue.Size())
	fmt.Printf("Is empty: %t\n", intQueue.IsEmpty())

	// Peek at front
	if front, ok := intQueue.Peek(); ok {
		fmt.Printf("Front item: %d\n", front)
	}

	// Dequeue all items
	for !intQueue.IsEmpty() {
		if item, ok := intQueue.Dequeue(); ok {
			fmt.Printf("Dequeued: %d\n", item)
		}
	}

	fmt.Printf("After dequeuing all items - Size: %d, IsEmpty: %t\n", intQueue.Size(), intQueue.IsEmpty())

	// Test with strings
	fmt.Println("\n=== Testing String Queue ===")
	stringQueue := NewQueue[string]()

	// Add some strings
	stringQueue.Enqueue("first")
	stringQueue.Enqueue("second")
	stringQueue.Enqueue("third")

	fmt.Printf("Queue size: %d\n", stringQueue.Size())

	// Dequeue and print
	for !stringQueue.IsEmpty() {
		if item, ok := stringQueue.Dequeue(); ok {
			fmt.Printf("Dequeued: %s\n", item)
		}
	}

	// Test dequeue from empty queue
	fmt.Println("\n=== Testing Empty Queue ===")
	emptyQueue := NewQueue[int]()
	if _, ok := emptyQueue.Dequeue(); !ok {
		fmt.Println("Cannot dequeue from empty queue - handled correctly!")
	}

	if _, ok := emptyQueue.Peek(); !ok {
		fmt.Println("Cannot peek empty queue - handled correctly!")
	}
}

Exercise Solution

The solution demonstrates advanced generic data structure concepts:

  1. Generic Type Definition: Queue[T any] with type parameter
  2. Constructor Pattern: NewQueue[T any]() for type-safe initialization
  3. Method Implementation: All methods work with the generic type T
  4. Zero Value Handling: Proper handling of empty queue operations
  5. Type Safety: Each queue instance is tied to a specific type

package main

import "fmt"

// Queue is a generic FIFO (First In, First Out) data structure
type Queue[T any] struct {
	items []T
}

// NewQueue creates a new empty queue
func NewQueue[T any]() *Queue[T] {
	return &Queue[T]{
		items: make([]T, 0),
	}
}

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

// Dequeue removes and returns the item from the front of the queue
// Returns the item and a boolean indicating success
func (q *Queue[T]) Dequeue() (T, bool) {
	var zero T
	if len(q.items) == 0 {
		return zero, false
	}

	item := q.items[0]
	clear(q.items[:1])
	q.items = q.items[1:] // Remove the first element
	return item, true
}

// Peek returns the front item without removing it
// Returns the item and a boolean indicating success
func (q *Queue[T]) Peek() (T, bool) {
	var zero T
	if len(q.items) == 0 {
		return zero, false
	}

	return q.items[0], true
}

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

// IsEmpty returns true if the queue is empty
func (q *Queue[T]) IsEmpty() bool {
	return len(q.items) == 0
}

func main() {
	// Test with integers
	fmt.Println("=== Testing Integer Queue ===")
	intQueue := NewQueue[int]()

	// Add some numbers
	intQueue.Enqueue(10)
	intQueue.Enqueue(20)
	intQueue.Enqueue(30)

	fmt.Printf("Queue size: %d\n", intQueue.Size())
	fmt.Printf("Is empty: %t\n", intQueue.IsEmpty())

	// Peek at front
	if front, ok := intQueue.Peek(); ok {
		fmt.Printf("Front item: %d\n", front)
	}

	// Dequeue all items
	for !intQueue.IsEmpty() {
		if item, ok := intQueue.Dequeue(); ok {
			fmt.Printf("Dequeued: %d\n", item)
		}
	}

	fmt.Printf("After dequeuing all items - Size: %d, IsEmpty: %t\n", intQueue.Size(), intQueue.IsEmpty())

	// Test with strings
	fmt.Println("\n=== Testing String Queue ===")
	stringQueue := NewQueue[string]()

	// Add some strings
	stringQueue.Enqueue("first")
	stringQueue.Enqueue("second")
	stringQueue.Enqueue("third")

	fmt.Printf("Queue size: %d\n", stringQueue.Size())

	// Dequeue and print
	for !stringQueue.IsEmpty() {
		if item, ok := stringQueue.Dequeue(); ok {
			fmt.Printf("Dequeued: %s\n", item)
		}
	}

	// Test dequeue from empty queue
	fmt.Println("\n=== Testing Empty Queue ===")
	emptyQueue := NewQueue[int]()
	if _, ok := emptyQueue.Dequeue(); !ok {
		fmt.Println("Cannot dequeue from empty queue - handled correctly!")
	}

	if _, ok := emptyQueue.Peek(); !ok {
		fmt.Println("Cannot peek empty queue - handled correctly!")
	}
}

Key Learning Points:

  • How to create generic data structures
  • Implementing methods on generic types
  • Handling zero values with var zero T
  • Constructor patterns for generic types
  • Type safety across multiple queue instances

Exercise (25 Minutes)

Objective: Implement generic Map and Reduce functions - two fundamental operations in functional programming.

Requirements:

Map Function:

  • Signature: Map[T any, U any](slice []T, fn func(T) U) []U
  • Transforms each element in a slice using the provided function
  • Returns a new slice with transformed values
  • Input type T and output type U can be different

Reduce Function:

  • Signature: Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U
  • Combines all elements in a slice into a single value
  • Takes an initial accumulator value
  • Applies the function to accumulator and each element sequentially
  • Returns the final accumulated value

Understanding Map and Reduce:

  • Map: Transforms each element independently (like applying a function to every item)

    • Example: Convert numbers to strings, square all numbers, get lengths of strings
  • Reduce: Combines elements into a single result (like folding a list)

    • Example: Sum, product, concatenation, finding min/max

Test Cases to Implement:

  1. Map integers to strings with formatting
  2. Map integers to squares
  3. Map strings to their lengths
  4. Reduce integers to sum
  5. Reduce integers to product
  6. Reduce strings to concatenated sentence
  7. Reduce integers to find maximum

package main

import "fmt"

// TODO: Implement the Map function
// Map transforms each element in a slice using the provided function
// func Map[T any, U any](slice []T, fn func(T) U) []U

// TODO: Implement the Reduce function
// Reduce combines all elements in a slice into a single value
// func Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U

func main() {
	// Test Map: Convert integers to strings
	numbers := []int{1, 2, 3, 4, 5}
	strings := Map(numbers, func(n int) string {
		return fmt.Sprintf("Number: %d", n)
	})
	fmt.Println("Mapped to strings:", strings)

	// Test Map: Square numbers
	squared := Map(numbers, func(n int) int {
		return n * n
	})
	fmt.Println("Squared numbers:", squared)

	// Test Map: Convert strings to their lengths
	words := []string{"go", "rust", "python", "javascript"}
	lengths := Map(words, func(s string) int {
		return len(s)
	})
	fmt.Println("Word lengths:", lengths)

	// Test Reduce: Sum of integers
	sum := Reduce(numbers, 0, func(acc int, n int) int {
		return acc + n
	})
	fmt.Println("Sum:", sum)

	// Test Reduce: Product of integers
	product := Reduce(numbers, 1, func(acc int, n int) int {
		return acc * n
	})
	fmt.Println("Product:", product)

	// Test Reduce: Concatenate strings
	sentence := Reduce(words, "", func(acc string, word string) string {
		if acc == "" {
			return word
		}
		return acc + " " + word
	})
	fmt.Println("Concatenated:", sentence)

	// Test Reduce: Find maximum value
	maxValues := []int{3, 7, 2, 9, 1, 5}
	max := Reduce(maxValues, maxValues[0], func(acc int, n int) int {
		if n > acc {
			return n
		}
		return acc
	})
	fmt.Println("Maximum:", max)
}

Exercise Solution

This solution demonstrates advanced generic programming concepts:

  1. Multiple Type Parameters: Both Map and Reduce use two type parameters (T and U)
  2. Type Transformation: Map shows how to transform from type T to type U
  3. Accumulator Pattern: Reduce demonstrates the fold/accumulator pattern
  4. Higher-Order Functions: Both functions accept functions as parameters
  5. Flexibility: The same functions work with any types and any transformation logic

Map Implementation:

func Map[T any, U any](slice []T, fn func(T) U) []U {
    result := make([]U, len(slice))  // Pre-allocate result slice
    for i, v := range slice {
        result[i] = fn(v)              // Transform each element
    }
    return result
}

Reduce Implementation:

func Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U {
    acc := initial                     // Start with initial value
    for _, v := range slice {
        acc = fn(acc, v)               // Accumulate each element
    }
    return acc
}

package main

import "fmt"

// Map transforms each element in a slice using the provided function
func Map[T any, U any](slice []T, fn func(T) U) []U {
	result := make([]U, len(slice))
	for i, v := range slice {
		result[i] = fn(v)
	}
	return result
}

// Reduce combines all elements in a slice into a single value
func Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U {
	acc := initial
	for _, v := range slice {
		acc = fn(acc, v)
	}
	return acc
}

func main() {
	// Test Map: Convert integers to strings
	numbers := []int{1, 2, 3, 4, 5}
	strings := Map(numbers, func(n int) string {
		return fmt.Sprintf("Number: %d", n)
	})
	fmt.Println("Mapped to strings:", strings)

	// Test Map: Square numbers
	squared := Map(numbers, func(n int) int {
		return n * n
	})
	fmt.Println("Squared numbers:", squared)

	// Test Map: Convert strings to their lengths
	words := []string{"go", "rust", "python", "javascript"}
	lengths := Map(words, func(s string) int {
		return len(s)
	})
	fmt.Println("Word lengths:", lengths)

	// Test Reduce: Sum of integers
	sum := Reduce(numbers, 0, func(acc int, n int) int {
		return acc + n
	})
	fmt.Println("Sum:", sum)

	// Test Reduce: Product of integers
	product := Reduce(numbers, 1, func(acc int, n int) int {
		return acc * n
	})
	fmt.Println("Product:", product)

	// Test Reduce: Concatenate strings
	sentence := Reduce(words, "", func(acc string, word string) string {
		if acc == "" {
			return word
		}
		return acc + " " + word
	})
	fmt.Println("Concatenated:", sentence)

	// Test Reduce: Find maximum value
	maxValues := []int{3, 7, 2, 9, 1, 5}
	max := Reduce(maxValues, maxValues[0], func(acc int, n int) int {
		if n > acc {
			return n
		}
		return acc
	})
	fmt.Println("Maximum:", max)
}

Key Learning Points:

  • How to use two different type parameters in one function
  • Transforming between different types (Map[int, string])
  • Accumulator pattern for combining values (Reduce)
  • Higher-order functions with generics
  • Pre-allocating slices for better performance (make([]U, len(slice)))
  • The power of functional programming patterns with type safety

Real-World Applications:

  • Map: Data transformation pipelines, DTO conversions, formatting collections
  • Reduce: Aggregations, statistics, combining configuration, building complex objects from lists

What You've Learned

Through all three exercises in this module, you’ve practiced:

  1. Generic Functions: Creating reusable functions that work with any type
  2. Generic Data Structures: Building type-safe containers
  3. Type Parameters: Using [T any] to make code flexible
  4. Type Safety: Maintaining compile-time type checking
  5. Zero Values: Properly handling empty states in generic code
  6. Constructor Patterns: Creating instances of generic types
  7. Method Implementation: Adding behavior to generic types

These concepts form the foundation for writing flexible, reusable, and type-safe Go code using generics.