Skip to content
Yuvraj 🧢
Github - yindiaGithub - tqindiaContact

Go Generics Deep Dive: From Type Parameters to Real-World Applications

— golang, generics, type-safety, programming, go1.18, data-structures, performance — 26 min read

After years of heated debates, Go finally embraced generics in version 1.18. This wasn't just adding angle brackets to the language - it was a carefully designed system that maintains Go's simplicity while solving real type-safety problems.

But generics in Go are different. They're not Java generics with type erasure, nor C++ templates with code bloat. Go implemented a unique approach using type parameters and constraints that feels natural to Go developers while providing compile-time type safety and zero runtime overhead.

This deep dive explores every aspect of Go generics - from basic syntax to advanced patterns, performance implications, and real-world applications. You'll learn not just how to use generics, but when and why to use them.


Understanding Type Parameters

The Problem Generics Solve

Before generics, Go developers faced a trilemma:

// Option 1: Write duplicate code for each type
func MinInt(a, b int) int {
if a < b {
return a
}
return b
}
func MinFloat64(a, b float64) float64 {
if a < b {
return a
}
return b
}
func MinString(a, b string) string {
if a < b {
return a
}
return b
}
// Option 2: Use interface{} and lose type safety
func Min(a, b interface{}) interface{} {
// Runtime type assertions needed
switch v := a.(type) {
case int:
if b, ok := b.(int); ok && v < b {
return v
}
case float64:
if b, ok := b.(float64); ok && v < b {
return v
}
// ... more cases
}
return a
}
// Option 3: Code generation (go generate)
//go:generate genny -in=min.go -out=gen-min.go gen "T=int,float64,string"

Basic Generic Syntax

With generics, we write the logic once:

// Generic function with type parameter T
func Min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
// Usage - type inference makes it clean
result1 := Min(5, 3) // Min[int](5, 3)
result2 := Min(3.14, 2.71) // Min[float64](3.14, 2.71)
result3 := Min("hello", "go") // Min[string]("hello", "go")
// Explicit type specification when needed
result4 := Min[float32](3.14, 2.71)

Multiple Type Parameters

Functions and types can have multiple type parameters:

// Generic function with multiple type parameters
func Transform[T, 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
}
// Usage
numbers := []int{1, 2, 3, 4, 5}
strings := Transform(numbers, func(n int) string {
return fmt.Sprintf("Number: %d", n)
})
// strings = ["Number: 1", "Number: 2", ...]
// Generic type with multiple parameters
type Pair[T, U any] struct {
First T
Second U
}
// Methods on generic types
func (p Pair[T, U]) Swap() Pair[U, T] {
return Pair[U, T]{First: p.Second, Second: p.First}
}
// Usage
p1 := Pair[string, int]{"age", 30}
p2 := p1.Swap() // Pair[int, string]{30, "age"}

Type Constraints In-Depth

The Constraint System

Type constraints define what operations are allowed on type parameters:

// Basic constraint using interface
type Numeric interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
// The ~ (tilde) means "underlying type"
// This allows custom types based on these primitives
type MyInt int // MyInt satisfies ~int
func Sum[T Numeric](values []T) T {
var sum T // Zero value of T
for _, v := range values {
sum += v // + operator available due to constraint
}
return sum
}

Built-in Constraints

The constraints package provides useful predefined constraints:

import "golang.org/x/exp/constraints"
// constraints.Ordered - types that support <, <=, >, >=
func Sort[T constraints.Ordered](slice []T) {
sort.Slice(slice, func(i, j int) bool {
return slice[i] < slice[j]
})
}
// constraints.Integer - all integer types
func Abs[T constraints.Integer | constraints.Float](x T) T {
if x < 0 {
return -x
}
return x
}
// constraints.Signed - signed integers
// constraints.Unsigned - unsigned integers
// constraints.Float - floating point types

Custom Constraints with Methods

Constraints can require specific methods:

// Constraint requiring String() method
type Stringer interface {
String() string
}
// Generic function using method constraint
func PrintAll[T Stringer](items []T) {
for _, item := range items {
fmt.Println(item.String())
}
}
// More complex constraint combining methods and types
type Number interface {
~int | ~int64 | ~float64
String() string // Also requires String method
}
// Constraint with multiple methods
type Comparable[T any] interface {
Compare(T) int
Equal(T) bool
}
// Using the constraint
func FindMax[T Comparable[T]](items []T) T {
if len(items) == 0 {
var zero T
return zero
}
max := items[0]
for _, item := range items[1:] {
if item.Compare(max) > 0 {
max = item
}
}
return max
}

Constraint Embedding and Composition

// Embedding constraints
type OrderedStringer interface {
constraints.Ordered
fmt.Stringer
}
// Union constraints with methods
type ComparableNumber interface {
~int | ~int64 | ~float64
Compare(ComparableNumber) int
}
// Recursive constraints for self-referential types
type Node[T any] interface {
Value() T
Children() []Node[T]
}
// Type lists with additional requirements
type Container[T any] interface {
~[]T | ~map[string]T
Len() int
}

Generic Data Structures

Generic Stack Implementation

// Thread-safe generic stack
type Stack[T any] struct {
mu sync.RWMutex
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{
items: make([]T, 0),
}
}
func (s *Stack[T]) Push(item T) {
s.mu.Lock()
defer s.mu.Unlock()
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
s.mu.Lock()
defer s.mu.Unlock()
var zero T
if len(s.items) == 0 {
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
func (s *Stack[T]) Peek() (T, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
var zero T
if len(s.items) == 0 {
return zero, false
}
return s.items[len(s.items)-1], true
}
func (s *Stack[T]) Size() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.items)
}
// Usage
intStack := NewStack[int]()
intStack.Push(42)
intStack.Push(17)
value, ok := intStack.Pop() // 17, true
stringStack := NewStack[string]()
stringStack.Push("hello")
stringStack.Push("world")

Generic Binary Search Tree

// Generic BST with ordered constraint
type TreeNode[T constraints.Ordered] struct {
Value T
Left *TreeNode[T]
Right *TreeNode[T]
}
type BST[T constraints.Ordered] struct {
root *TreeNode[T]
size int
}
func NewBST[T constraints.Ordered]() *BST[T] {
return &BST[T]{}
}
func (bst *BST[T]) Insert(value T) {
bst.root = bst.insertNode(bst.root, value)
bst.size++
}
func (bst *BST[T]) insertNode(node *TreeNode[T], value T) *TreeNode[T] {
if node == nil {
return &TreeNode[T]{Value: value}
}
if value < node.Value {
node.Left = bst.insertNode(node.Left, value)
} else if value > node.Value {
node.Right = bst.insertNode(node.Right, value)
}
return node
}
func (bst *BST[T]) Find(value T) bool {
return bst.findNode(bst.root, value)
}
func (bst *BST[T]) findNode(node *TreeNode[T], value T) bool {
if node == nil {
return false
}
if value == node.Value {
return true
} else if value < node.Value {
return bst.findNode(node.Left, value)
} else {
return bst.findNode(node.Right, value)
}
}
func (bst *BST[T]) InOrder() []T {
result := make([]T, 0, bst.size)
bst.inOrderTraversal(bst.root, &result)
return result
}
func (bst *BST[T]) inOrderTraversal(node *TreeNode[T], result *[]T) {
if node == nil {
return
}
bst.inOrderTraversal(node.Left, result)
*result = append(*result, node.Value)
bst.inOrderTraversal(node.Right, result)
}
// Usage
tree := NewBST[int]()
tree.Insert(5)
tree.Insert(3)
tree.Insert(7)
tree.Insert(1)
tree.Insert(9)
fmt.Println(tree.Find(7)) // true
fmt.Println(tree.InOrder()) // [1 3 5 7 9]

Generic LRU Cache

// Generic LRU cache with any comparable key type
type LRUCache[K comparable, V any] struct {
capacity int
items map[K]*list.Element
order *list.List
mu sync.Mutex
}
type cacheEntry[K comparable, V any] struct {
key K
value V
}
func NewLRUCache[K comparable, V any](capacity int) *LRUCache[K, V] {
return &LRUCache[K, V]{
capacity: capacity,
items: make(map[K]*list.Element),
order: list.New(),
}
}
func (c *LRUCache[K, V]) Get(key K) (V, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if elem, ok := c.items[key]; ok {
c.order.MoveToFront(elem)
entry := elem.Value.(cacheEntry[K, V])
return entry.value, true
}
var zero V
return zero, false
}
func (c *LRUCache[K, V]) Put(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
if elem, ok := c.items[key]; ok {
c.order.MoveToFront(elem)
entry := elem.Value.(cacheEntry[K, V])
entry.value = value
elem.Value = entry
return
}
if c.order.Len() >= c.capacity {
oldest := c.order.Back()
if oldest != nil {
c.order.Remove(oldest)
entry := oldest.Value.(cacheEntry[K, V])
delete(c.items, entry.key)
}
}
entry := cacheEntry[K, V]{key: key, value: value}
elem := c.order.PushFront(entry)
c.items[key] = elem
}
// Usage
cache := NewLRUCache[string, int](3)
cache.Put("a", 1)
cache.Put("b", 2)
cache.Put("c", 3)
cache.Put("d", 4) // Evicts "a"
val, ok := cache.Get("a") // 0, false (evicted)
val, ok = cache.Get("b") // 2, true (moved to front)

Type Inference and Instantiation

How Type Inference Works

Go's type inference for generics is sophisticated but predictable:

// Type inference from arguments
func Identity[T any](x T) T {
return x
}
// Inference examples
v1 := Identity(42) // T inferred as int
v2 := Identity("hello") // T inferred as string
v3 := Identity([]int{1, 2}) // T inferred as []int
// Type inference with multiple parameters
func Map[T, 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
}
// T inferred from first arg, U from function return type
doubled := Map([]int{1, 2, 3}, func(x int) int {
return x * 2
})
// Partial type inference
func Pair[T, U any](t T, u U) (T, U) {
return t, u
}
// Cannot partially specify types - must be all or nothing
// p := Pair[int](5, "hello") // Error!
p := Pair[int, string](5, "hello") // OK
p2 := Pair(5, "hello") // OK - both inferred

Instantiation and Type Checking

// Generic type instantiation
type Container[T any] struct {
items []T
}
// Explicit instantiation
var c1 Container[int] // Type is Container[int]
c2 := Container[string]{} // Initialized Container[string]
c3 := new(Container[float64]) // *Container[float64]
// Type alias with generics
type IntContainer = Container[int]
type StringList = []string
// Cannot create generic type aliases
// type MyContainer[T any] = Container[T] // Error!
// Generic interfaces
type Processor[T any] interface {
Process(T) T
}
// Implementing generic interface
type Doubler[T constraints.Integer | constraints.Float] struct{}
func (d Doubler[T]) Process(x T) T {
return x * 2
}
// Interface satisfaction
var _ Processor[int] = Doubler[int]{}
var _ Processor[float64] = Doubler[float64]{}

Type Parameter Constraints in Practice

// Constraint inference from usage
func Zero[T any]() T {
var zero T
return zero
}
// Inferred constraints from operations
func Add[T any](a, b T) T {
// This won't compile - T doesn't guarantee + operator
// return a + b // Error!
// Need explicit constraint
var zero T
return zero
}
// Correct version with constraint
func AddCorrect[T constraints.Integer | constraints.Float](a, b T) T {
return a + b // Now it works!
}
// Type switching with generics
func ProcessValue[T any](v T) string {
// Type switches work with type parameters
switch any(v).(type) {
case int:
return fmt.Sprintf("Integer: %d", v)
case string:
return fmt.Sprintf("String: %s", v)
case []byte:
return fmt.Sprintf("Bytes: %x", v)
default:
return fmt.Sprintf("Unknown: %v", v)
}
}

Advanced Generic Patterns

Builder Pattern with Generics

// Generic builder pattern
type Builder[T any] struct {
value T
errors []error
actions []func(*T) error
}
func NewBuilder[T any](initial T) *Builder[T] {
return &Builder[T]{
value: initial,
actions: make([]func(*T) error, 0),
}
}
func (b *Builder[T]) With(action func(*T) error) *Builder[T] {
b.actions = append(b.actions, action)
return b
}
func (b *Builder[T]) Build() (T, error) {
for _, action := range b.actions {
if err := action(&b.value); err != nil {
b.errors = append(b.errors, err)
}
}
if len(b.errors) > 0 {
var zero T
return zero, fmt.Errorf("build errors: %v", b.errors)
}
return b.value, nil
}
// Usage example
type User struct {
Name string
Email string
Age int
}
user, err := NewBuilder(User{}).
With(func(u *User) error {
u.Name = "John Doe"
return nil
}).
With(func(u *User) error {
u.Email = "john@example.com"
if !strings.Contains(u.Email, "@") {
return fmt.Errorf("invalid email")
}
return nil
}).
With(func(u *User) error {
u.Age = 30
if u.Age < 0 || u.Age > 150 {
return fmt.Errorf("invalid age")
}
return nil
}).
Build()

Option Pattern with Generics

// Generic option pattern
type Option[T any] func(*T)
type Server[T any] struct {
config T
}
func NewServer[T any](opts ...Option[T]) *Server[T] {
var config T
for _, opt := range opts {
opt(&config)
}
return &Server[T]{config: config}
}
// Concrete configuration type
type HTTPConfig struct {
Port int
Timeout time.Duration
MaxConns int
}
// Option functions
func WithPort(port int) Option[HTTPConfig] {
return func(c *HTTPConfig) {
c.Port = port
}
}
func WithTimeout(timeout time.Duration) Option[HTTPConfig] {
return func(c *HTTPConfig) {
c.Timeout = timeout
}
}
func WithMaxConnections(max int) Option[HTTPConfig] {
return func(c *HTTPConfig) {
c.MaxConns = max
}
}
// Usage
server := NewServer[HTTPConfig](
WithPort(8080),
WithTimeout(30*time.Second),
WithMaxConnections(100),
)

Functional Programming Patterns

// Generic functional utilities
func Map[T, 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
}
func Filter[T any](slice []T, predicate func(T) bool) []T {
result := make([]T, 0, len(slice))
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U {
result := initial
for _, v := range slice {
result = fn(result, v)
}
return result
}
func Compose[T, U, V any](f func(T) U, g func(U) V) func(T) V {
return func(t T) V {
return g(f(t))
}
}
// Pipeline pattern
func Pipeline[T any](initial T, fns ...func(T) T) T {
result := initial
for _, fn := range fns {
result = fn(result)
}
return result
}
// Usage examples
numbers := []int{1, 2, 3, 4, 5}
// Map example
squared := Map(numbers, func(x int) int { return x * x })
// [1, 4, 9, 16, 25]
// Filter example
evens := Filter(numbers, func(x int) bool { return x%2 == 0 })
// [2, 4]
// Reduce example
sum := Reduce(numbers, 0, func(acc, x int) int { return acc + x })
// 15
// Compose example
addOne := func(x int) int { return x + 1 }
double := func(x int) int { return x * 2 }
addOneThenDouble := Compose(addOne, double)
result := addOneThenDouble(3) // (3 + 1) * 2 = 8
// Pipeline example
processed := Pipeline(5,
func(x int) int { return x + 1 },
func(x int) int { return x * 2 },
func(x int) int { return x - 3 },
) // ((5 + 1) * 2) - 3 = 9

Result/Option Types

// Generic Result type for error handling
type Result[T any] struct {
value T
err error
}
func Ok[T any](value T) Result[T] {
return Result[T]{value: value}
}
func Err[T any](err error) Result[T] {
return Result[T]{err: err}
}
func (r Result[T]) IsOk() bool {
return r.err == nil
}
func (r Result[T]) IsErr() bool {
return r.err != nil
}
func (r Result[T]) Unwrap() T {
if r.err != nil {
panic(r.err)
}
return r.value
}
func (r Result[T]) UnwrapOr(defaultValue T) T {
if r.err != nil {
return defaultValue
}
return r.value
}
func (r Result[T]) Map[U any](fn func(T) U) Result[U] {
if r.err != nil {
return Err[U](r.err)
}
return Ok(fn(r.value))
}
func (r Result[T]) AndThen[U any](fn func(T) Result[U]) Result[U] {
if r.err != nil {
return Err[U](r.err)
}
return fn(r.value)
}
// Generic Option type
type Option[T any] struct {
value *T
}
func Some[T any](value T) Option[T] {
return Option[T]{value: &value}
}
func None[T any]() Option[T] {
return Option[T]{value: nil}
}
func (o Option[T]) IsSome() bool {
return o.value != nil
}
func (o Option[T]) IsNone() bool {
return o.value == nil
}
func (o Option[T]) Unwrap() T {
if o.value == nil {
panic("unwrap on None")
}
return *o.value
}
func (o Option[T]) UnwrapOr(defaultValue T) T {
if o.value == nil {
return defaultValue
}
return *o.value
}
// Usage
func divide(a, b int) Result[int] {
if b == 0 {
return Err[int](fmt.Errorf("division by zero"))
}
return Ok(a / b)
}
result := divide(10, 2)
if result.IsOk() {
fmt.Println("Result:", result.Unwrap()) // Result: 5
}
doubled := result.Map(func(x int) int { return x * 2 })
fmt.Println(doubled.UnwrapOr(0)) // 10

Performance Implications

Compilation and Binary Size

// Understanding generic instantiation
// This generic function...
func GenericMax[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
// ...gets instantiated for each type used:
// - GenericMax[int]
// - GenericMax[float64]
// - GenericMax[string]
// Each instantiation creates separate machine code
// Measuring binary size impact
// File: main.go
package main
import "fmt"
// Non-generic version
func MaxInt(a, b int) int {
if a > b { return a }
return b
}
// Generic version
func Max[T constraints.Ordered](a, b T) T {
if a > b { return a }
return b
}
func main() {
// Non-generic: single function
fmt.Println(MaxInt(5, 3))
// Generic: creates 3 instantiations
fmt.Println(Max(5, 3)) // Max[int]
fmt.Println(Max(5.5, 3.3)) // Max[float64]
fmt.Println(Max("b", "a")) // Max[string]
}
// Build and check size:
// go build -o non_generic main_non_generic.go
// go build -o generic main_generic.go
// ls -lh non_generic generic

Runtime Performance

// Benchmarking generics vs interfaces
package main
import (
"testing"
)
// Interface-based approach
type Comparable interface {
Less(Comparable) bool
}
type Int int
func (i Int) Less(other Comparable) bool {
return i < other.(Int)
}
func MinInterface(a, b Comparable) Comparable {
if a.Less(b) {
return a
}
return b
}
// Generic approach
func MinGeneric[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
// Direct approach
func MinInt(a, b int) int {
if a < b {
return a
}
return b
}
// Benchmarks
func BenchmarkInterface(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = MinInterface(Int(5), Int(3))
}
}
func BenchmarkGeneric(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = MinGeneric(5, 3)
}
}
func BenchmarkDirect(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = MinInt(5, 3)
}
}
// Results (typical):
// BenchmarkInterface-8 50000000 25.3 ns/op 16 B/op 1 allocs/op
// BenchmarkGeneric-8 1000000000 0.25 ns/op 0 B/op 0 allocs/op
// BenchmarkDirect-8 1000000000 0.25 ns/op 0 B/op 0 allocs/op

Memory Allocation Patterns

// Generic slice operations and memory
func GrowSlice[T any](slice []T, newSize int) []T {
if cap(slice) >= newSize {
return slice[:newSize]
}
// Calculate new capacity (same as runtime)
newCap := cap(slice)
doubleCap := newCap + newCap
if newSize > doubleCap {
newCap = newSize
} else {
if len(slice) < 1024 {
newCap = doubleCap
} else {
for newCap < newSize {
newCap += newCap / 4
}
}
}
// Allocate new backing array
newSlice := make([]T, newSize, newCap)
copy(newSlice, slice)
return newSlice
}
// Zero-allocation generic pool
type Pool[T any] struct {
pool sync.Pool
}
func NewPool[T any](newFunc func() T) *Pool[T] {
return &Pool[T]{
pool: sync.Pool{
New: func() interface{} {
return newFunc()
},
},
}
}
func (p *Pool[T]) Get() T {
return p.pool.Get().(T)
}
func (p *Pool[T]) Put(x T) {
p.pool.Put(x)
}
// Usage - zero allocations in hot path
bufferPool := NewPool(func() []byte {
return make([]byte, 1024)
})
func ProcessData(data []byte) {
buf := bufferPool.Get()
defer bufferPool.Put(buf)
// Use buf for processing
// No allocation if pool has available buffers
}

Real-World Applications

Generic HTTP Router

// Type-safe HTTP router with generics
type Handler[T any] func(ctx context.Context, req T) (any, error)
type Router[T any] struct {
routes map[string]Handler[T]
mu sync.RWMutex
}
func NewRouter[T any]() *Router[T] {
return &Router[T]{
routes: make(map[string]Handler[T]),
}
}
func (r *Router[T]) Handle(pattern string, handler Handler[T]) {
r.mu.Lock()
defer r.mu.Unlock()
r.routes[pattern] = handler
}
func (r *Router[T]) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mu.RLock()
handler, ok := r.routes[req.URL.Path]
r.mu.RUnlock()
if !ok {
http.NotFound(w, req)
return
}
// Decode request
var payload T
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Call handler
response, err := handler(req.Context(), payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Encode response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// Usage
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
router := NewRouter[CreateUserRequest]()
router.Handle("/users", func(ctx context.Context, req CreateUserRequest) (any, error) {
// Validate
if req.Name == "" || req.Email == "" {
return nil, fmt.Errorf("name and email required")
}
// Create user (simplified)
user := UserResponse{
ID: generateID(),
Name: req.Name,
Email: req.Email,
}
return user, nil
})
http.ListenAndServe(":8080", router)

Generic Database Repository

// Generic repository pattern
type Entity interface {
GetID() string
SetID(string)
}
type Repository[T Entity] struct {
db *sql.DB
tableName string
columns []string
scanFunc func(*sql.Row) (T, error)
}
func NewRepository[T Entity](db *sql.DB, tableName string, columns []string, scanFunc func(*sql.Row) (T, error)) *Repository[T] {
return &Repository[T]{
db: db,
tableName: tableName,
columns: columns,
scanFunc: scanFunc,
}
}
func (r *Repository[T]) FindByID(ctx context.Context, id string) (T, error) {
query := fmt.Sprintf("SELECT %s FROM %s WHERE id = $1",
strings.Join(r.columns, ", "), r.tableName)
row := r.db.QueryRowContext(ctx, query, id)
return r.scanFunc(row)
}
func (r *Repository[T]) FindAll(ctx context.Context) ([]T, error) {
query := fmt.Sprintf("SELECT %s FROM %s",
strings.Join(r.columns, ", "), r.tableName)
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var results []T
for rows.Next() {
// Note: This is simplified - real implementation would handle scanning properly
var entity T
results = append(results, entity)
}
return results, rows.Err()
}
func (r *Repository[T]) Create(ctx context.Context, entity T) error {
if entity.GetID() == "" {
entity.SetID(generateID())
}
placeholders := make([]string, len(r.columns))
for i := range placeholders {
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
r.tableName,
strings.Join(r.columns, ", "),
strings.Join(placeholders, ", "))
// Execute insert (simplified - would need actual values)
_, err := r.db.ExecContext(ctx, query)
return err
}
// Usage with concrete type
type User struct {
ID string
Name string
Email string
}
func (u User) GetID() string { return u.ID }
func (u *User) SetID(id string) { u.ID = id }
userRepo := NewRepository[*User](
db,
"users",
[]string{"id", "name", "email"},
func(row *sql.Row) (*User, error) {
var u User
err := row.Scan(&u.ID, &u.Name, &u.Email)
return &u, err
},
)
user, err := userRepo.FindByID(ctx, "user-123")

Generic Event Bus

// Type-safe event bus with generics
type Event interface {
Type() string
}
type EventHandler[T Event] func(context.Context, T) error
type EventBus[T Event] struct {
handlers map[string][]EventHandler[T]
mu sync.RWMutex
}
func NewEventBus[T Event]() *EventBus[T] {
return &EventBus[T]{
handlers: make(map[string][]EventHandler[T]),
}
}
func (eb *EventBus[T]) Subscribe(eventType string, handler EventHandler[T]) {
eb.mu.Lock()
defer eb.mu.Unlock()
eb.handlers[eventType] = append(eb.handlers[eventType], handler)
}
func (eb *EventBus[T]) Publish(ctx context.Context, event T) error {
eb.mu.RLock()
handlers := eb.handlers[event.Type()]
eb.mu.RUnlock()
var wg sync.WaitGroup
errors := make(chan error, len(handlers))
for _, handler := range handlers {
wg.Add(1)
go func(h EventHandler[T]) {
defer wg.Done()
if err := h(ctx, event); err != nil {
errors <- err
}
}(handler)
}
wg.Wait()
close(errors)
// Collect errors
var errs []error
for err := range errors {
errs = append(errs, err)
}
if len(errs) > 0 {
return fmt.Errorf("event handling errors: %v", errs)
}
return nil
}
// Usage
type UserEvent struct {
UserID string
Action string
Timestamp time.Time
}
func (e UserEvent) Type() string {
return e.Action
}
eventBus := NewEventBus[UserEvent]()
// Subscribe to events
eventBus.Subscribe("user.created", func(ctx context.Context, event UserEvent) error {
fmt.Printf("User created: %s at %v\n", event.UserID, event.Timestamp)
return nil
})
eventBus.Subscribe("user.created", func(ctx context.Context, event UserEvent) error {
// Send welcome email
return sendWelcomeEmail(event.UserID)
})
// Publish event
eventBus.Publish(context.Background(), UserEvent{
UserID: "user-123",
Action: "user.created",
Timestamp: time.Now(),
})

Common Pitfalls and Best Practices

Type Inference Limitations

// Pitfall: Type inference doesn't work with type assertions
func Process[T any](v interface{}) T {
// This won't compile - can't infer T
// return v.(T) // Error!
// Need to use type parameter explicitly
return v.(T)
}
// Must specify type explicitly
result := Process[string]("hello")
// Pitfall: Can't use type parameters in type assertions
func Convert[T any](v interface{}) (T, bool) {
// This pattern doesn't work as expected
result, ok := v.(T) // T is not a type, it's a type parameter
return result, ok
}
// Solution: Use type switches or reflection
func ConvertSafe[T any](v interface{}) (T, bool) {
if typed, ok := v.(T); ok {
return typed, true
}
var zero T
return zero, false
}

Method Set Restrictions

// Pitfall: Pointer vs value receivers with generics
type Container[T any] struct {
value T
}
// Value receiver
func (c Container[T]) GetValue() T {
return c.value
}
// Pointer receiver
func (c *Container[T]) SetValue(v T) {
c.value = v
}
// This works
var c1 Container[int]
val := c1.GetValue() // OK - value receiver
// This also works
var c2 *Container[int] = new(Container[int])
c2.SetValue(42) // OK - pointer receiver
val2 := c2.GetValue() // OK - pointer has access to value methods
// But be careful with interfaces
type Getter[T any] interface {
GetValue() T
}
var _ Getter[int] = Container[int]{} // OK
var _ Getter[int] = &Container[int]{} // OK
type Setter[T any] interface {
SetValue(T)
}
// var _ Setter[int] = Container[int]{} // Error! Value doesn't have pointer method
var _ Setter[int] = &Container[int]{} // OK

Recursive Type Constraints

// Pitfall: Recursive constraints can be tricky
type Comparable[T any] interface {
CompareTo(T) int
}
// This won't work - circular reference
// type SelfComparable[T SelfComparable[T]] interface {
// CompareTo(T) int
// }
// Solution: Use concrete types
type Node[T any] struct {
Value T
Children []*Node[T] // Self-reference is OK in concrete types
}
// Or use interface with methods
type TreeNode interface {
GetValue() any
GetChildren() []TreeNode
}

Performance Best Practices

// Best Practice: Minimize allocations in generic functions
func Sum[T constraints.Integer | constraints.Float](values []T) T {
var sum T // Use zero value, no allocation
for _, v := range values {
sum += v
}
return sum
}
// Avoid unnecessary boxing
func Bad[T any](values []T) []interface{} {
result := make([]interface{}, len(values))
for i, v := range values {
result[i] = v // Boxing happens here
}
return result
}
// Better: Keep type safety
func Good[T any](values []T, fn func(T) T) []T {
result := make([]T, len(values))
for i, v := range values {
result[i] = fn(v) // No boxing
}
return result
}
// Best Practice: Use constraints to enable optimizations
func MinWithConstraint[T constraints.Ordered](a, b T) T {
if a < b { // Direct comparison, no interface calls
return a
}
return b
}
// Less efficient with interface
func MinWithInterface[T interface{ Less(T) bool }](a, b T) T {
if a.Less(b) { // Interface method call
return a
}
return b
}

When to Use Generics

// Good use cases for generics:
// 1. Data structures
type Stack[T any] struct {
items []T
}
// 2. Algorithms that work on multiple types
func BinarySearch[T constraints.Ordered](arr []T, target T) int {
// Implementation
}
// 3. Type-safe containers
type SafeMap[K comparable, V any] struct {
mu sync.RWMutex
m map[K]V
}
// 4. Reducing code duplication
func Max[T constraints.Ordered](values ...T) T {
// One implementation for all ordered types
}
// When NOT to use generics:
// 1. When interface{} is actually appropriate
func PrintJSON(v interface{}) {
data, _ := json.Marshal(v)
fmt.Println(string(data))
}
// 2. When only one type is ever used
func ProcessUserData(users []User) {
// Specific to User type
}
// 3. When reflection is necessary anyway
func DeepCopy(v interface{}) interface{} {
// Needs reflection regardless
}
// 4. When it makes code harder to understand
// Simple is better than clever

Migration Guide

Converting Existing Code to Generics

// Before: Interface-based approach
type Comparer interface {
Compare(Comparer) int
}
type IntWrapper int
func (i IntWrapper) Compare(other Comparer) int {
o := other.(IntWrapper)
if i < o {
return -1
} else if i > o {
return 1
}
return 0
}
func FindMaxInterface(items []Comparer) Comparer {
if len(items) == 0 {
return nil
}
max := items[0]
for _, item := range items[1:] {
if item.Compare(max) > 0 {
max = item
}
}
return max
}
// After: Generic approach
func FindMax[T constraints.Ordered](items []T) (T, bool) {
if len(items) == 0 {
var zero T
return zero, false
}
max := items[0]
for _, item := range items[1:] {
if item > max {
max = item
}
}
return max, true
}
// Migration benefits:
// 1. No type assertions needed
// 2. No wrapper types required
// 3. Better performance (no interface calls)
// 4. Cleaner API

Gradual Migration Strategy

// Step 1: Identify duplicate code patterns
func SumInts(nums []int) int {
sum := 0
for _, n := range nums {
sum += n
}
return sum
}
func SumFloat64s(nums []float64) float64 {
sum := 0.0
for _, n := range nums {
sum += n
}
return sum
}
// Step 2: Create generic version
func Sum[T constraints.Integer | constraints.Float](nums []T) T {
var sum T
for _, n := range nums {
sum += n
}
return sum
}
// Step 3: Add compatibility layer during migration
var SumInts = Sum[int] // Type alias for compatibility
var SumFloat64s = Sum[float64]
// Step 4: Update call sites gradually
// Old code continues to work
total := SumInts([]int{1, 2, 3})
// New code uses generic version
total := Sum([]int{1, 2, 3})
// Step 5: Remove old functions after migration complete

Conclusion

Go generics represent a carefully balanced addition to the language. They provide type safety and code reuse without sacrificing Go's simplicity or runtime performance. The key insights:

  1. Type parameters enable code reuse while maintaining type safety
  2. Constraints define capabilities, not just types
  3. Type inference keeps code clean and readable
  4. Zero runtime overhead - generics compile to efficient code
  5. Gradual adoption - use generics where they provide clear value

The best Go generic code doesn't try to be clever - it solves real problems with clear, maintainable solutions. Start with simple use cases like containers and algorithms, then expand as you become comfortable with the patterns.

Remember: generics are a tool, not a requirement. Use them when they make your code cleaner, safer, and more maintainable. The Go community's measured approach to generics means they integrate naturally with existing Go idioms while opening new possibilities for type-safe, reusable code.


For more examples and patterns, check out the Go generics proposal and the experimental packages that showcase generic implementations.

© 2025 by Yuvraj 🧢. All rights reserved.
Theme by LekoArts