Building a Type-Safe Concurrent Cache with Go 1.18 Generics
Go 1.18 generics let you write a single concurrent cache that the compiler instantiates per key-value type pair, catching mismatches at build time. This post shows a working implementation, a two-cache example, and the trade-offs around binary size.
04 Aug 2026, 06:09 UTC

The Problem: Caching Without Compromising Type Safety
Before Go 1.18, building a reusable cache meant choosing between interface{} with runtime type assertions, code generation, or duplicating implementations for each key-value pair. All three approaches have real costs: interface{} defers type errors to runtime, code generation adds build complexity, and duplication increases maintenance burden.
Go 1.18 generics change this equation. You can now write a single cache implementation that the compiler instantiates for each concrete type pair, catching mismatches at compile time while keeping the code you maintain in one place.
What the Generic Cache Looks Like
The core idea is a struct with type parameters for the key and value, constrained so the key can be used as a map key and the value can be anything:
type Cache[K comparable, V any] struct {
mu sync.RWMutex
data map[K]V
}
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{data: make(map[K]V)}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
func (c *Cache[K, V]) Set(key K, val V) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = val
}
func (c *Cache[K, V]) Delete(key K) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
The comparable constraint on K is required because map keys must support == and !=. The any constraint on V (an alias for interface{}) accepts any type. Methods on the generic receiver *Cache[K, V] work exactly like methods on a concrete type.
Worked Example: Caching User Sessions and Feature Flags
Suppose a service needs two caches: one mapping session IDs to user structs, another mapping feature flag names to boolean values. With the generic cache, both are one-liners:
type User struct {
ID string
Email string
IsActive bool
}
func main() {
sessionCache := NewCache[string, User]()
flagCache := NewCache[string, bool]()
sessionCache.Set("sess-123", User{ID: "u-1", Email: "[contact removed]", IsActive: true})
flagCache.Set("new-checkout", true)
if user, ok := sessionCache.Get("sess-123"); ok {
fmt.Printf("User: %s (%s)\n", user.Email, user.ID)
}
if enabled, ok := flagCache.Get("new-checkout"); ok && enabled {
fmt.Println("Feature flag is ON")
}
}
Run this with Go 1.18 or later:
$ go version
# must show go1.18 or newer
$ go run main.go
User: [contact removed] (u-1)
Feature flag is ON
To verify there are no data races, run the race detector:
$ go run -race main.go
# no warnings means the RWMutex protects all accesses correctly
The race detector instruments memory accesses at runtime; if it reports no races, the lock ordering in Get, Set, and Delete is sound for concurrent callers.
Trade-offs: Binary Size and Compile Time
Each distinct [K, V] pair used in your program causes the compiler to generate a separate copy of the cache methods (monomorphization). In the example above, two copies are produced: one for [string, User] and one for [string, bool]. If you instantiate the cache with dozens of type pairs across a large codebase, binary size and compile time grow proportionally.
You can inspect the generated assembly to confirm monomorphization:
$ go tool compile -S main.go | grep -E "Cache\[.*\]\.Get|Cache\[.*\]\.Set"
# shows separate function symbols for each instantiation
For most services the impact is negligible, but in latency-sensitive build pipelines or size-constrained deployments (e.g., AWS Lambda packages), it's worth measuring. A practical mitigation is to limit the number of instantiations by using a small set of key types (string, int64) and encoding complex keys as strings.
When to Reach for This Pattern
Use a generic cache when you have multiple caches with different value types and want compile-time safety without code generation. Avoid it if you only ever need one cache type—the concrete version is simpler and avoids any monomorphization cost. Also consider that overly complex type signatures (e.g., nested generics with multiple constraints) can hurt readability; keep the public API surface small.
To adopt incrementally, start by replacing a single interface{}-based cache with the generic version, run your test suite with -race, and verify the binary size delta is acceptable. The compiler will catch any call sites passing the wrong key or value type immediately.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.