The Copy-Paste Trap
You are building a service. You need a stack of incoming requests to process them in reverse order. You write a struct with a slice and Push and Pop methods. It works. Two days later, you need a stack of error messages for a retry loop. You copy the stack code, rename it, change the type from Request to string, and hope you didn't miss a spot. You missed a spot. The bug hides for a week.
Go 1.18 introduced type parameters to stop this cycle. You write the structure once. You use it for requests, strings, or any type. The compiler guarantees safety. Go does not ship with a library of generic collections like Vector<T> or LinkedList<T>. The standard library relies on slices and maps for everyday work. Type parameters let you build custom structures when the built-ins fall short.
Slices and Maps are the Workhorses
Before reaching for type parameters, remember that slice and map are generic by design. You specify the element type when you declare them. A []int is a dynamic array of integers. A map[string]User is a hash table mapping strings to user structs. You do not need a wrapper class. The slice is the vector. The map is the hash map.
Think of a slice like a label card in a warehouse. The card points to a row of shelves, notes how many items are in the row, and records how much space is reserved. The card is small. You can pass the card to a function without moving the items. The type on the card ensures you only put apples in the apple bin. The compiler enforces the shape at the moment you create the slice.
Maps work similarly but use a hash function to find items. You provide a key, and the map calculates where the value lives. Lookups are fast. The key type must be comparable. You can compare strings and integers. You cannot compare slices or maps. The compiler rejects invalid keys immediately.
Slices and maps cover 95% of data structure needs. Use them first.
Writing Your Own Generic Structure
When you need custom behavior, type parameters let you define a structure that works with any type. You declare a type parameter in square brackets after the name. The constraint specifies what types are allowed. any is the wildcard. It means any type is permitted.
package main
import "fmt"
// Stack holds a collection of items with LIFO access.
type Stack[T any] struct {
items []T // Slice stores the elements. T allows any type.
}
// Push adds an item to the top of the stack.
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item) // Append grows the slice automatically.
}
// Pop removes and returns the top item.
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T // Zero value of T acts as a safe default.
return zero, false // Return false to signal the stack is empty.
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1] // Shrink slice to discard the item.
return item, true
}
func main() {
// Create a stack of integers.
intStack := &Stack[int]{}
intStack.Push(10)
intStack.Push(20)
val, ok := intStack.Pop()
fmt.Println(val, ok) // 20 true
// Create a stack of strings.
strStack := &Stack[string]{}
strStack.Push("hello")
valStr, okStr := strStack.Pop()
fmt.Println(valStr, okStr) // hello true
}
The compiler sees Stack[int] and Stack[string] and generates two separate versions of the code. This is monomorphization. You get type safety without runtime overhead. The runtime sees only concrete types. There is no type erasure. If you try to push a string into intStack, the compiler rejects the program with cannot use "hello" (untyped string constant) as int value in argument.
Convention aside: receiver naming. The receiver is (s *Stack[T]). The name s matches the type Stack. Keep receivers short. Use one or two letters. Do not use this or self. The community expects concise receiver names.
Generics are for reuse, not cleverness. Write the structure once. Use it everywhere.
Type Constraints and Comparability
The any constraint is flexible but limiting. You cannot compare values of type any. If your structure needs to sort items or check for duplicates, you need a stronger constraint. The comparable constraint restricts the type to values that support == and !=.
Strings, integers, floats, booleans, and structs with comparable fields are comparable. Slices, maps, and functions are not. If you try to use a slice as a map key, the compiler stops you with invalid map key type slice.
// Set stores unique items of a comparable type.
type Set[T comparable] struct {
items map[T]struct{} // Map tracks existence. Empty struct uses zero bytes.
}
// Add inserts an item into the set.
func (s *Set[T]) Add(item T) {
s.items[item] = struct{}{} // Map assignment handles uniqueness automatically.
}
// Contains checks if an item exists.
func (s *Set[T]) Contains(item T) bool {
_, ok := s.items[item]
return ok // Boolean result indicates presence.
}
You can also define custom constraints using interface syntax. This is useful when you need methods on the type.
// Stringer is a constraint requiring a String method.
type Stringer interface {
String() string
}
// Logger stores items that can be converted to strings.
type Logger[T Stringer] struct {
entries []T
}
// Log adds an entry and prints it.
func (l *Logger[T]) Log(item T) {
l.entries = append(l.entries, item)
fmt.Println(item.String()) // Constraint guarantees String method exists.
}
If you pass a type that does not satisfy the constraint, the compiler rejects it with type parameter T does not satisfy Stringer. Trust the constraints. The compiler is your safety net.
Realistic Example: A Generic Cache
Real code often needs a cache. A generic cache with expiration demonstrates type parameters, constraints, and concurrency. Map keys must be comparable. Values can be anything.
package main
import (
"fmt"
"sync"
"time"
)
// CacheEntry stores a value and its expiration time.
type CacheEntry[V any] struct {
Value V
ExpiresAt time.Time
}
// Cache provides a simple in-memory store with expiration.
type Cache[K comparable, V any] struct {
data map[K]CacheEntry[V] // Map requires comparable keys.
mu sync.Mutex // Mutex protects concurrent access.
}
// Set adds a value to the cache with a TTL.
func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock() // Unlock ensures the lock releases even if panic occurs.
c.data[key] = CacheEntry[V]{
Value: value,
ExpiresAt: time.Now().Add(ttl),
}
}
// Get retrieves a value if it exists and hasn't expired.
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.data[key]
if !ok {
var zero V
return zero, false
}
if time.Now().After(entry.ExpiresAt) {
delete(c.data, key) // Remove expired entry to save memory.
var zero V
return zero, false
}
return entry.Value, true
}
func main() {
cache := &Cache[string, int]{}
cache.Set("count", 42, time.Second*5)
val, ok := cache.Get("count")
fmt.Println(val, ok) // 42 true
}
The cache uses K comparable for keys and V any for values. The mutex ensures thread safety. The defer statement guarantees the lock releases. This pattern is common in Go. Wrap shared state in a mutex. Use defer for cleanup.
Convention aside: context.Context always goes as the first parameter. If this cache made network calls, the methods would accept ctx context.Context. Functions that take a context should respect cancellation and deadlines. This cache is local, so it does not need context. Know the difference.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and Compiler Errors
Generic code introduces new failure modes. The most common issue is using a non-comparable type where a comparable type is required. If you define a cache with Cache[[]byte, string], the compiler rejects it with invalid map key type slice. Slices are not comparable. Convert the slice to a string or use a custom type with a comparable representation.
Another trap is the nil slice versus an empty slice. A nil slice has a length of zero and no underlying array. An empty slice has a length of zero but may have an underlying array. JSON marshaling treats them differently. A nil slice marshals to null. An empty slice marshals to []. Initialize slices with make if you need an empty array in JSON output.
Type parameter syntax can be verbose. Constraints require careful definition. If you write type Stack[T ~int] struct, you restrict the stack to integers and named types based on integers. Passing a string results in type parameter T does not satisfy ~int. Read the error messages. They tell you exactly what went wrong.
The container/list package provides a linked list. It uses any for elements. It allocates a node for every element. Slices reuse the underlying array. Cache locality matters. Iterating a slice is fast. Iterating a linked list jumps around memory. The container/list is rarely the right choice. Use it only when you need frequent insertions and deletions in the middle of a sequence and you have measured that a slice is too slow.
The worst goroutine bug is the one that never logs. The worst data structure bug is the one that allocates memory you do not need. Profile before optimizing.
Decision Matrix
Use a slice when you need an ordered list or dynamic array. Slices are fast, cache-friendly, and support efficient iteration. Use a map when you need fast lookups by key. Maps provide constant-time access for comparable keys. Use a generic struct when you need custom behavior like a stack, queue, or cache that reuses logic across types. Use container/heap when you need a priority queue. The heap package implements a binary heap with minimal overhead. Use container/list only when you need frequent O(1) insertions and deletions in the middle of a sequence and you have measured that a slice is too slow.
Slices are the default. Maps are for lookups. Generics are for reuse, not cleverness.