How to Use Generics with Structs in Go

Define type parameters in square brackets after the struct name to create reusable, type-safe data structures.

The problem with copy-pasting types

You are building a simple queue for a background job processor. The first version handles int job IDs. It works fine. Then the product team asks for a queue that holds string request tokens. You copy the file, rename it, and change int to string. Two weeks later, you need a queue for User objects. You are now maintaining three identical files that differ only by one word. The alternative is to use interface{} and sprinkle type assertions throughout your code. That approach compiles, but it pushes type errors into runtime and makes your editor lose autocomplete. Generics let you write the queue logic once and specify the payload type at the call site.

How generic structs actually work

A generic struct is a template that defers type decisions until instantiation. Think of it like a shipping container. The container itself has a standard shape, locking mechanisms, and weight ratings. It does not care whether it carries steel beams, electronics, or frozen fish. You decide the contents when you load it. In Go, you declare the container with a type parameter in square brackets, like [T any]. The T acts as a placeholder. The any constraint says the placeholder can accept any type. When you create a value, you replace T with a concrete type. The compiler then verifies that every operation inside the struct is valid for that specific type.

Type parameters live in square brackets immediately after the struct name. You can declare one or more. The convention is to use single uppercase letters that hint at the role: T for a single type, K and V for key and value, E for element. The compiler treats these as aliases until instantiation. Once you supply a concrete type, the alias disappears and the generated code uses the real type.

Generic structs are templates. The compiler fills them in before your program ever runs.

A minimal example

Here is the simplest generic struct: a wrapper that holds a single value and exposes it through a method.

// Box holds a single value of any type.
type Box[T any] struct {
    // Value stores the payload. The concrete type is determined at instantiation.
    Value T
}

// NewBox creates a Box and assigns the provided value.
func NewBox[T any](v T) Box[T] {
    // Return a struct literal with the inferred type parameter.
    return Box[T]{Value: v}
}

// Get returns the stored value without type assertions.
func (b *Box[T]) Get() T {
    // Direct field access preserves the exact type.
    return b.Value
}

func main() {
    // Type inference fills in [int] automatically from the argument.
    intBox := NewBox(42)
    // Explicit type parameter is required when the compiler cannot infer it.
    stringBox := Box[string]{Value: "payload"}
}

The compiler treats Box[int] and Box[string] as completely separate types. You cannot assign a Box[int] to a variable of type Box[string]. The type system catches mismatches before the program runs. Type inference works on function calls but not on struct literals. When you call NewBox(42), the compiler sees the argument type and fills in [int]. When you write Box[string]{Value: "payload"}, you must specify the type parameter explicitly because the struct literal does not provide enough context for inference. This is a deliberate design choice. Explicit type parameters on struct literals prevent ambiguous assignments and keep the type checker predictable.

What the compiler does behind the scenes

Go handles generics through compile-time monomorphization. When the compiler encounters Box[int], it generates a specialized version of the struct and all its methods where every T is replaced with int. It does the same for Box[string]. If you never instantiate Box[bool], the compiler never generates code for it. This approach keeps memory overhead to zero at runtime. There is no hidden interface conversion, no reflection lookup, and no dynamic dispatch. The generated code looks identical to hand-written concrete types.

The type checker runs against each specialized version. If your generic method tries to call .Len() on T, the compiler only complains when you actually instantiate Box[MyType] and MyType lacks a Len() method. This lazy checking keeps compilation fast and focuses errors on the code you actually use.

Convention aside: gofmt handles generic formatting automatically. Do not fight indentation around type parameters. Let the tool decide. Most editors run it on save, and the Go team enforces a single formatting style across the entire ecosystem.

Generic structs compile to concrete code. Your runtime pays nothing for the abstraction.

Realistic usage: a constrained cache

Real code rarely stores a single value. You usually need collections, caches, or adapters. Here is a thread-safe cache that maps keys to values. It uses two type parameters and a constraint to ensure keys support equality checks.

// Cache stores key-value pairs with a simple expiration mechanism.
type Cache[K comparable, V any] struct {
    // items holds the stored data. comparable ensures keys support == and !=.
    items map[K]V
    // mu protects concurrent access to the underlying map.
    mu sync.RWMutex
}

// NewCache allocates the backing map and returns a ready-to-use cache.
func NewCache[K comparable, V any]() *Cache[K, V] {
    // Initialize the map so Set does not panic on nil assignment.
    return &Cache[K, V]{items: make(map[K]V)}
}

// Set adds or updates a key-value pair in the cache.
func (c *Cache[K, V]) Set(key K, value V) {
    // Lock writes to prevent data races on the underlying map.
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = value
}

// Get retrieves a value by key and reports whether it exists.
func (c *Cache[K, V]) Get(key K) (V, bool) {
    // Read lock allows concurrent reads without blocking each other.
    c.mu.RLock()
    defer c.mu.RUnlock()
    // Return the zero value of V if the key is missing.
    val, ok := c.items[key]
    return val, ok
}

Notice the receiver signature: (c *Cache[K, V]). The type parameters travel with the struct into every method. You do not repeat the constraints in the method definition. The constraint [K comparable, V any] lives only on the struct declaration. This keeps method signatures clean and enforces a single source of truth for type rules.

The comparable constraint is built into the language. It restricts K to types that support == and !=. Maps in Go require comparable keys, so the constraint aligns perfectly with the underlying data structure. If you try to use a slice or a map as a key, the compiler stops you immediately.

Convention aside: receiver names in Go are typically one or two letters that match the type, like c for Cache or s for Stack. Avoid this or self. The community treats receiver naming as a style rule, not a language requirement, but sticking to short names keeps method signatures readable.

Constraints are boundaries. Define them early and the compiler enforces them everywhere.

Pitfalls and compiler guardrails

Generics introduce a few traps that trip up developers coming from other languages. The first trap is assuming any means you can perform operations on the type. any is just an alias for interface{}. It gives you a blank check. If your code tries to add two T values together, the compiler rejects it with invalid operation: a + b (operator + not defined on T). You must use a constraint like constraints.Integer or constraints.Float to unlock arithmetic. The constraints package in the standard library provides ready-made bounds for numbers, strings, and ordered types.

The second trap is trying to attach type parameters to methods. Go does not allow method-level generics. You cannot write func (c *Cache) Get[K comparable](key K). Type parameters must live on the struct, interface, or function definition. If you need a method that behaves differently based on a type, you either move the type parameter to the struct or use a type switch inside a concrete method.

The third trap involves zero values. Every generic type has a zero value. For Box[int], it is 0. For Box[*User], it is nil. When you declare a variable like var b Box[T], the compiler initializes it with the zero value of T. This is consistent with Go's default initialization rules, but it can surprise you if you expect a generic container to start with a pre-allocated slice or map. You must initialize those fields explicitly in a constructor.

Another common mistake is forgetting that generic types are distinct. Cache[string, int] and Cache[string, int64] are unrelated types. You cannot pass one to a function expecting the other, even if the underlying data looks similar. The type system treats them as completely separate entities. This strictness prevents accidental data corruption but requires careful planning when designing APIs that accept multiple cache variants.

If you forget to import a package that defines a constraint, you get undefined: constraints from the compiler. If you try to use a generic struct without specifying the type parameters where inference fails, you get missing type arguments. The compiler is verbose by design. Read the error message. It tells you exactly which type parameter is missing or which constraint is violated.

Generics are not a replacement for careful design. They are a tool for reducing duplication while preserving type safety.

When to reach for generics

Use a generic struct when you need a container or adapter that behaves identically across many types and you want compile-time type checking. Use a concrete struct when the type is fixed by the domain and adding a type parameter would only complicate the API. Use an interface when you need to accept multiple unrelated types that share a common behavior, like io.Reader or fmt.Stringer. Use any when you are building a low-level serialization library or a debugging tool that must accept absolutely everything without knowing the shape ahead of time. Use a type switch when you need to handle a small, closed set of types with different logic for each.

Pick the simplest tool that matches your data shape. Generics shine when the logic is identical and only the payload changes.

Where to go next