The cache that needed to hold everything
You are building a caching layer for a web service. First, you need to store user sessions as strings. A week later, the product team asks for a cache that holds integer counters for rate limiting. Then comes a request to cache complex query results. You also need a function that takes any cached item and writes it to disk if it hasn't been accessed in an hour.
You face a choice. You could write a separate cache for every type, duplicating the logic for expiration and eviction. You could use interface{} and lose type safety, forcing every caller to perform manual type assertions. Or you can use the tools Go provides to handle both the structure and the behavior without sacrificing safety.
Contracts versus molds
Interfaces describe behavior. They answer the question "What can this thing do?" If you have a Reader interface, you don't care if the data comes from a file, a network socket, or a string in memory. You only care that you can call Read. Interfaces define a contract. Any type that implements the required methods satisfies the contract.
Generics describe structure. They answer the question "How do I handle this data without knowing its specific type?" Generics let you write a function or a data structure once and reuse it for many types, while the compiler keeps track of which type is which. Generics define a mold. You pour different materials into the same mold to get the same shape. The mold doesn't care if the material is plastic, metal, or clay, as long as it fits.
Interfaces are contracts. Generics are molds.
Minimal example
Here is a cache that uses generics for storage and an interface for behavior.
// Cache stores items of type T.
type Cache[T any] struct {
// items holds the data. T can be any type.
items map[string]T
}
// Get retrieves an item by key.
func (c *Cache[T]) Get(key string) (T, bool) {
// Return the value and a boolean indicating existence.
val, ok := c.items[key]
return val, ok
}
// Saver defines behavior for types that can be persisted.
type Saver interface {
// Save writes the object to storage.
Save() error
}
// Persist calls Save on any Saver.
func Persist(s Saver) error {
// The compiler ensures s has a Save method.
return s.Save()
}
Code once. Use everywhere. The compiler handles the rest.
What happens at compile time and runtime
When you use a generic like Cache[string], the compiler generates a version of the code specifically for strings. It checks that every operation you perform on T is valid for string. If you try to call a method that strings don't have, the compiler rejects the code. This happens at compile time. The generated code is efficient because the compiler knows the exact type. Generics do not add runtime overhead compared to non-generic code. The compiler generates the specialized code for each type you use.
Interfaces work differently. When you pass a value to a function expecting an interface, the compiler wraps the value in a small structure containing a pointer to the type information and a pointer to the data. This happens at runtime. The call to a method goes through the type information. This allows flexibility but adds a tiny bit of overhead. The compiler checks that the type satisfies the interface, but the actual dispatch happens when the program runs.
Generics compile to specialized code. Interfaces dispatch at runtime.
Realistic example: Repository and Notifier
You are building a service that manages entities. You have a User and a Post. Both need to be stored and retrieved. You also have a Notifier that sends emails. You want a generic repository for storage and an interface for the notification behavior.
import "context"
// Repository handles storage for any type T.
type Repository[T any] struct {
// store is a map keyed by ID.
store map[string]T
}
// Save adds or updates an item.
func (r *Repository[T]) Save(ctx context.Context, id string, item T) error {
// Check context for cancellation before writing.
if err := ctx.Err(); err != nil {
return err
}
// Store the item. The key is always a string.
r.store[id] = item
return nil
}
// Notifiable defines behavior for types that can send notifications.
type Notifiable interface {
// Notify sends a message to the owner.
Notify() error
}
// ProcessItem saves the item and notifies if applicable.
func ProcessItem[T any](repo *Repository[T], id string, item T) error {
// Create a context for the operation.
ctx := context.Background()
// Save the item first.
if err := repo.Save(ctx, id, item); err != nil {
return err
}
// Check if the item supports notifications.
if n, ok := any(item).(Notifiable); ok {
// Call Notify only if the type implements it.
return n.Notify()
}
return nil
}
The receiver name r matches the type Repository. This is the Go convention. Use one or two letters that hint at the type, not this or self. The function ProcessItem accepts a generic parameter T, but it checks for the Notifiable interface at runtime. This combines the safety of generics for storage with the flexibility of interfaces for behavior.
The context.Context is the first parameter, named ctx. Functions that take a context should respect cancellation and deadlines. This is plumbing. Run it through every long-lived call site.
Combine generics for storage and interfaces for behavior.
Constraints and type sets
Generics constraints look like interfaces, but they can do more. A constraint can restrict T to specific types, not just methods. This is called a type set.
// Number restricts T to numeric types.
type Number interface {
int | int64 | float64
}
// Sum adds two numbers.
func Sum[T Number](a, b T) T {
// T supports addition.
return a + b
}
Here, Number is a constraint that allows only int, int64, or float64. You cannot pass a string to Sum. The compiler rejects it with invalid operation: a + b (operator + not defined on T) if you try to use a type that doesn't support addition.
You can also use the comparable constraint to allow equality checks.
// FindIndex returns the index of the first occurrence of val.
func FindIndex[T comparable](slice []T, val T) int {
// Iterate and compare.
for i, v := range slice {
if v == val {
return i
}
}
return -1
}
If you omit comparable and try to use ==, the compiler complains with invalid operation: v == val (operator == not defined on T). The compiler needs to know that T supports equality.
Constraints let you restrict types by structure. Interfaces restrict by behavior.
Pitfalls and compiler errors
If you define a generic constraint that is too broad, the compiler stops you from using type-specific operations. You get invalid operation: t1 == t2 (operator == not defined on T) if you try to compare two values of type T when T is unconstrained. The compiler needs to know that T supports equality. You fix this by adding a constraint like comparable.
If you force an interface conversion without checking, the program panics at runtime with interface conversion: interface {} is not MyInterface. Always use the comma-ok idiom: val, ok := x.(MyInterface). The underscore _ discards a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors. Here, ok is important, so don't discard it.
Don't use generics to solve a behavior problem. If you find yourself writing a generic function that only calls methods on T, you probably need an interface. Generics shine when you are manipulating the data structure itself, not when you are invoking behavior.
The compiler is your friend. Read the error. Fix the constraint.
Decision matrix
Use an interface when you need to define behavior that multiple unrelated types share. Use an interface when you want to decouple your code from concrete implementations. Use an interface when you are writing a function that operates on capabilities rather than structure.
Use a generic when you are building a data structure that stores values of any type. Use a generic when you need to write a function that operates on the structure of the data rather than its behavior. Use a generic when you want to preserve type information across function calls without boxing values into interface{}.
Use a generic constraint when you need to restrict the types allowed to those that satisfy specific methods or type properties. Use comparable when you need equality checks. Use a type set like int | int64 when you need to limit to specific types.
Use any only when you truly cannot define the behavior or structure ahead of time, such as in JSON parsing or reflection-heavy code. any is an alias for interface{}. It erases type information. Prefer generics or interfaces when possible.
Accept interfaces, return structs. This is the most common Go style mantra. When you write a function, accept an interface so callers can pass any type that satisfies the behavior. Return a concrete struct so callers know exactly what they got. Generics fit into this pattern. You can accept a generic parameter, but you usually return a value of that generic type. The caller knows the type because they provided it.
Strings are cheap. Don't pass *string. Strings are immutable and passed by value efficiently. If your generic constraint involves strings, pass them directly. The compiler handles the copying.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. If your generic data structure holds channels, ensure you close them. A goroutine waiting on a channel in a generic cache will leak if the cache is discarded without cleanup. Always provide a Close method or use context.Context to signal shutdown. The worst goroutine bug is the one that never logs.
Pick the tool that matches the problem.