How to Use Generics with Methods in Go (And the Limitations)

Use type parameters in function signatures to create reusable, type-safe code in Go, noting constraints on instantiation and inference.

Generics on methods: the receiver stays concrete

You are building a cache. You want a Get method that works for any value type. You write a struct and add a method with type parameters. The compiler accepts it. Then you try to make the cache itself generic, or you try to attach type parameters to the receiver, and the compiler rejects the code. Or you call the method and the type inference fails, leaving you staring at a red squiggle.

Go supports generic methods, but with a hard boundary: type parameters belong to the method, not the receiver. The receiver must be a concrete type. This rule keeps the type system predictable and prevents infinite method sets. You can write a method that handles any type, but the thing holding the method must be fully specified.

Type parameters live on the method, not the receiver

In Go, a method is just a function with a receiver. Generics add type parameters to the signature. Those type parameters can appear on the function name, but they cannot appear on the receiver type. The receiver must be a complete type that the compiler can resolve immediately.

Think of the receiver as the anchor. It defines the method set. If the receiver could be generic, the method set would change depending on how you instantiate the type, which breaks interface satisfaction and dispatch. Go avoids this by forcing the receiver to be concrete. The method can introduce new type parameters, but the receiver cannot.

This means you can have a generic method on a non-generic type. You can also have a generic method on a generic type, provided the receiver uses the type parameters defined by the type. You cannot add new type parameters to the receiver itself.

Minimal example: generic method on a concrete type

Here is a generic method attached to a plain struct. The struct has no type parameters. The method introduces a type parameter T. The receiver is Logger, which is concrete.

package main

import "fmt"

type Logger struct {
    prefix string
}

// Log prints a value of any type with the logger's prefix.
// T is inferred from the argument v.
func (l Logger) Log[T any](v T) {
    // The prefix comes from the receiver.
    // The value comes from the generic argument.
    fmt.Printf("[%s] %v\n", l.prefix, v)
}

func main() {
    l := Logger{prefix: "INFO"}
    // T is inferred as int from the argument 42.
    l.Log(42)
    // T is inferred as string from the argument "hello".
    l.Log("hello")
}

The compiler generates specialized versions of Log for each type used at the call site. The receiver l is always Logger. The type parameter T is resolved when you call l.Log(42). The method body sees T as int in that instance. The receiver remains fixed.

Generic methods on concrete types are useful when the operation varies by type but the state is uniform. A logger, a validator, or a serializer often fits this pattern. The tool does the same job for many types, but the tool itself doesn't change.

Receivers are concrete. Methods can be generic. Keep them separate.

Realistic example: generic type with methods

When the data structure itself holds different types, you define a generic type. The type parameters appear on the type definition. Methods on that type can use those parameters. The receiver must specify the type parameters exactly as defined.

Here is a stack that holds any type. The type Stack[T] is generic. The method Push uses T from the type definition. The receiver is *Stack[T], which is a concrete instantiation of the generic type.

package main

import "fmt"

// Stack holds a sequence of items of type T.
type Stack[T any] struct {
    items []T
}

// Push adds an item to the top of the stack.
// The receiver is a pointer to allow modification.
// T is already defined by the type Stack[T].
func (s *Stack[T]) Push(item T) {
    // Append modifies the underlying slice.
    // The pointer receiver ensures the change persists.
    s.items = append(s.items, item)
}

// Pop removes and returns the top item.
// Returns the item and a boolean indicating success.
func (s *Stack[T]) Pop() (T, bool) {
    // Check if the stack is empty.
    if len(s.items) == 0 {
        // Return zero value and false.
        var zero T
        return zero, false
    }
    // Grab the last item.
    item := s.items[len(s.items)-1]
    // Shrink the slice.
    s.items = s.items[:len(s.items)-1]
    return item, true
}

func main() {
    // Instantiate a stack of integers.
    var s Stack[int]
    s.Push(10)
    s.Push(20)
    
    // Pop returns int and bool.
    val, ok := s.Pop()
    fmt.Println(val, ok) // 20 true
}

The receiver *Stack[T] is valid because T is defined by the type. The method does not introduce new type parameters. It uses the existing ones. If you tried to add a new type parameter to the receiver, like func (s *Stack[T, U]), the compiler would reject it because Stack only defines T.

Convention aside: receiver names should be short and match the type. s for Stack, l for Logger, c for Cache. Never use this or self. Go idioms favor one or two letters. The name should hint at the type, not the role.

Generic types define the shape. Methods fill in the behavior. The receiver locks the shape.

Pitfalls: inference, interfaces, and errors

Generic methods introduce three common traps. Type inference can fail when the compiler cannot determine the type parameter. Generic methods cannot satisfy interface methods. And the receiver cannot introduce type parameters.

Inference failures

The compiler infers type parameters from arguments. If a method has a type parameter that does not appear in the arguments, inference fails. You must provide the type explicitly.

type Converter struct{}

// Convert transforms a value to type U.
// U does not appear in the arguments.
func (c Converter) Convert[T any, U any](v T) U {
    var zero U
    return zero
}

func main() {
    c := Converter{}
    // This fails. The compiler cannot infer U.
    // Error: cannot infer type argument for U
    // c.Convert(42)
    
    // You must specify U explicitly.
    result := c.Convert[int, float64](42)
    _ = result
}

The compiler rejects the call with cannot infer type argument for U. The fix is to provide the type arguments at the call site: c.Convert[int, float64](42). Use explicit instantiation when the type parameter is only in the return type or a constraint, not in the arguments.

Generic methods cannot satisfy interfaces

This is a major limitation. A method with type parameters cannot satisfy an interface method. Interfaces require concrete method signatures. A generic method has no single signature; it represents a family of methods.

type Printer interface {
    Print()
}

type Widget struct{}

// Print has a type parameter.
func (w Widget) Print[T any](v T) {
    // ...
}

func main() {
    var p Printer
    // This fails. Widget does not implement Printer.
    // Error: Widget does not implement Printer (Print method has type parameters)
    p = Widget{}
}

The compiler complains with Widget does not implement Printer (Print method has type parameters). You cannot use a generic method to fulfill an interface contract. If you need polymorphism, define a non-generic method that wraps the generic logic, or use a generic function instead of a method.

Generic methods break interface satisfaction. Design interfaces around concrete behavior, not generic operations.

Receiver cannot be generic

You cannot add type parameters to the receiver. The receiver must match the defined type exactly.

type Box struct{}

// This is invalid. Box has no type parameters.
// func (b Box[T]) Open() {}
// Error: type parameter T not defined

The compiler rejects this with type parameter T not defined. The receiver Box is concrete. You cannot invent a type parameter on the receiver. If you need a type parameter, put it on the method: func (b Box) Open[T any]().

Receivers are anchors. They cannot float. Define type parameters on the method or the type, never on the receiver alone.

Decision: when to use generics vs alternatives

Choose the right tool based on where the type variation lives. Use parallel structures to keep your code readable.

Use a generic method when the operation varies by type but the receiver is fixed. Use a generic type when the data structure itself holds different types. Use an interface when you need polymorphism across unrelated types. Use a non-generic method when the type is known and fixed. Use reflection only when you cannot use generics and the type is truly dynamic, accepting the performance cost.

Generic methods shine for utilities attached to a concrete type. Generic types shine for containers and collections. Interfaces shine for behavior contracts. Reflection is a last resort.

Where to go next