The slice capacity trap
You write a generic helper to filter a list of items. The code compiles. The tests pass. You deploy to production. Three hours later, the memory graph looks like a staircase climbing toward the ceiling. You check the logs. No errors. The slice length is zero. But the capacity is still huge. The garbage collector can't touch the underlying array because your generic function kept a reference to it.
This is the silent killer of Go generics. Slices are views into arrays. When you pass a slice to a function, you pass a header containing a pointer, a length, and a capacity. Generic functions often return new slice headers that point to the same underlying array. If you don't manage the capacity, you hold onto memory long after you think you're done.
Generics are molds, not magic
Generics let you write code once and use it with many types. Go implements this with type parameters. You declare a type parameter with a constraint. The compiler generates specific versions of your function for each concrete type you use. This is efficient. It also means the compiler checks constraints at compile time.
Think of a generic function as a mold for casting parts. You pour material into the mold. The mold shapes the material. But the mold has requirements. You can't pour water into a metal mold; it won't hold shape. You can't pour a rock; it won't fit. Go's constraints are the rules of the mold. If the type doesn't fit the constraint, the compiler stops you.
The constraint defines what operations you can perform on the type. If you need to add values, the constraint must include types that support addition. If you need to use the type as a map key, the constraint must include comparable types. The compiler rejects anything outside the constraint.
// Sum calculates the total of a slice of numbers.
// It only works with types that support addition.
func Sum[T int | float64](s []T) T {
// Declare a zero value of type T.
// This initializes total to 0 for int or 0.0 for float64.
var total T
for _, v := range s {
// Add each element to the total.
// The compiler verifies T supports the + operator.
total += v
}
return total
}
Trust gofmt. The tool formats generic code consistently. Don't argue about indentation or spacing. Most editors run gofmt on save. Focus on logic, not formatting.
The comparable constraint
Maps in Go require keys that support equality comparison. Not all types are comparable. Slices, maps, and functions cannot be used as map keys. If you write a generic function that uses a type parameter as a map key, you must constrain the type to comparable.
The comparable constraint is a predeclared interface. It includes all types that support == and !=. If you forget this constraint, the compiler rejects the program.
// Cache stores values by key.
// The key must be comparable to work as a map key.
type Cache[K comparable, V any] struct {
// data holds the cached values.
// The map key K must satisfy the comparable constraint.
data map[K]V
}
// Get retrieves a value from the cache.
// It returns the value and a boolean indicating presence.
func (c *Cache[K, V]) Get(key K) (V, bool) {
// Look up the key in the map.
// The compiler allows this because K is comparable.
val, ok := c.data[key]
return val, ok
}
If you try to use a slice as a key, the compiler complains with invalid type []int for map key: slice can only be used as map key if its type is comparable. This error saves you from a runtime panic. The compiler enforces the rule before the code runs.
Receiver naming follows convention. Use one or two letters matching the type. (c *Cache) is correct. (this *Cache) or (self *Cache) breaks convention. Go code reads better with short receiver names.
Memory leaks in slice operations
Slice operations like slices.Delete or append can return slices that share the underlying array. The returned slice has a smaller length but potentially the same capacity. If you hold a reference to the returned slice, you keep the entire underlying array alive.
This is common in generic code. You write a function to remove items from a slice. The function returns the modified slice. The caller assigns the result. The length shrinks. The capacity stays large. The memory leak is invisible in the length.
// RemoveAt removes the element at index i from the slice.
// It returns the modified slice.
func RemoveAt[T any](s []T, i int) []T {
// slices.Delete returns a slice that may share the underlying array.
// The returned slice has a smaller length but potentially the same capacity.
result := slices.Delete(s, i, i+1)
// Assigning the result updates the slice header.
// The caller must use the returned slice to see the change.
return result
}
If you ignore the return value, the original slice retains the deleted element. The function modifies a copy of the header. The caller sees no change. The compiler warns with result of RemoveAt[...] is not used if you don't capture the return.
Even if you assign the result, the capacity might remain high. If the underlying array is large, the garbage collector can't reclaim it. You need to break the link to the array. Use slices.Clone to create a new slice with a fresh array.
// Compact removes elements and releases unused capacity.
// It returns a new slice with minimal capacity.
func Compact[T any](s []T, keep func(T) bool) []T {
// Filter the slice.
// The result may share the underlying array.
filtered := slices.DeleteFunc(s, func(t T) bool {
return !keep(t)
})
// Clone the slice to break the link to the original array.
// This allows the garbage collector to reclaim memory.
return slices.Clone(filtered)
}
Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Always have a cancellation path. Memory leaks in slices happen when you hold a reference to a large underlying array. Always check capacity after slicing operations.
Type assertions with any
The any type is an alias for interface{}. It represents any type. You can use any as a type parameter constraint when you need to store or pass a value without caring about its type. But any doesn't help with type assertions.
If you have a value of type T where T is constrained to any, you can't perform a type assertion directly. You need to use a type switch or a type assertion on the concrete value. Generics don't provide dynamic type recovery.
// Stringify converts a value to a string.
// It handles different types using a type switch.
func Stringify[T any](v T) string {
// Use a type switch to handle concrete types.
// The type parameter T is erased at runtime.
switch val := any(v).(type) {
case int:
return fmt.Sprintf("%d", val)
case string:
return val
case bool:
return fmt.Sprintf("%t", val)
default:
return fmt.Sprintf("%v", val)
}
}
The type switch converts v to any and checks the concrete type. This works because any is an interface. The compiler allows the conversion. The runtime performs the type check.
If you try to use T in a type assertion, the compiler rejects it with cannot use type parameter T in type assertion. Type assertions require concrete types or interfaces. Type parameters are not concrete types until instantiation.
Public names start with a capital letter. Private names start lowercase. No keywords like public or private. Stringify is public. data in Cache is private. This controls visibility across packages.
Realistic example: processing requests
You build an HTTP handler that processes a batch of requests. You need to filter out invalid requests and cache the results. You use generics to handle different request types. You need to manage memory and constraints carefully.
// Request represents a generic request.
// It includes a payload and metadata.
type Request[T any] struct {
// Payload holds the request data.
Payload T
// ID uniquely identifies the request.
ID string
}
// ProcessBatch filters and processes a batch of requests.
// It returns the valid requests.
func ProcessBatch[T any](reqs []Request[T], validate func(T) bool) []Request[T] {
// Pre-allocate result slice to avoid reallocations.
valid := make([]Request[T], 0, len(reqs))
for _, req := range reqs {
// Validate the payload.
// The validate function checks the concrete type.
if validate(req.Payload) {
valid = append(valid, req)
}
}
// Return the filtered slice.
// The caller should check capacity if memory is a concern.
return valid
}
The validate function takes T. The caller provides a function that matches the concrete type. The compiler checks the signature. If the function signature doesn't match, you get cannot use func(T) bool as type func(S) bool in argument.
Error handling follows convention. if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't hide errors. Return them explicitly.
Pitfalls and compiler errors
Generics introduce new error messages. Understanding these errors helps you fix code quickly.
If you use a type parameter without a constraint, the compiler rejects it with type parameter T used without constraint. You must specify what T can be.
If you use a type parameter as a map key without comparable, you get type parameter T used as map key must have comparable type. Add comparable to the constraint.
If you pass a function with the wrong signature, you get cannot use func(T) bool as type func(S) bool in argument. Check the type parameters and return types.
If you forget to import a package, you get undefined: pkg. If you import a package and don't use it, you get imported and not used. Go requires all imports to be used.
The worst goroutine bug is the one that never logs. Profile your code. Check memory usage. Watch for slice capacity leaks. Generics don't change the rules of memory management. They add new ways to break them.
Decision: when to use constraints
Use any when you need to store or pass a value without caring about its type, but you won't perform operations on it.
Use comparable when you need to use the type as a map key or in a switch statement.
Use a union constraint like int | float64 when you need to support specific types that share operations.
Use a custom interface constraint when you need to call methods on the type.
Use slices.Clone when you need to break the link to the underlying array and prevent memory retention.
Use explicit type assertions when you have an any value and need to recover the concrete type.
Use plain sequential code when you don't need generics: the simplest thing that works is usually the right thing.
Constraints are contracts. The compiler enforces them. Write tight constraints. Slices are views. Capacity is the trap. Always check capacity after slicing operations.