When Not to Use Generics
If the code only calls methods, use an interface; reach for a type parameter when the body is identical apart from the type.
func Do(w io.Writer) error // calls methods: interface, not [T io.Writer]Type parameters are not a newer, better interface. They answer a different question. An interface describes what a value can do. A type parameter stands in for what a value is. Choosing the wrong one gives code that compiles and still annoys everyone who reads it.
The test
Ask what the function does with the value. If it only calls methods on it, take an interface. If the function needs the value's real type, to return one, to store one in a container, or to compare two of them, and the body would be identical for every type, take a type parameter.
Three shapes that look generic and are not
- One interface, used only for its methods.
func Log[T io.Writer](w T, s string)isio.Writerwritten the long way round. - A type switch inside the body. The bodies differ, so they should be separate functions, or the difference belongs behind a method.
- Exactly one type today. Write the concrete version. Making it generic later is a small change, while every reader of the generic version pays now.
What interfaces still do better
A []io.Writer can hold a file and a buffer at the same time. A []T cannot, because T is one type for each instantiation. An interface also lets a caller pass a type your package has never seen.
The standard library as a guide
io.Copy(dst Writer, src Reader) takes interfaces, because it only calls Read and Write. slices.Contains[S ~[]E, E comparable](s S, v E) bool takes type parameters, because its body is the same for every element type and it has to compare real values.
Example
package main
import (
"fmt"
"slices"
)
type Shape interface{ Area() float64 }
type Square struct{ Side float64 }
func (s Square) Area() float64 { return s.Side * s.Side }
type Rect struct{ W, H float64 }
func (r Rect) Area() float64 { return r.W * r.H }
// Describe only calls a method, so it takes the interface. One slice
// can then hold a Square and a Rect together.
func Describe(s Shape) string {
return fmt.Sprintf("area %.1f", s.Area())
}
// Keys has no method to call. It must hand back the map's real key
// type, so it takes a type parameter instead.
func Keys[K comparable, V any](m map[K]V) []K {
out := make([]K, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func main() {
for _, s := range []Shape{Square{Side: 2}, Rect{W: 3, H: 4}} {
fmt.Println(Describe(s))
}
ks := Keys(map[string]int{"b": 2, "a": 1})
slices.Sort(ks) // map order varies, so sort before printing
fmt.Println(ks)
}
// Output:
// area 4.0
// area 12.0
// [a b]
When to use it
- Deciding between func(w io.Writer) and func[T io.Writer](w T) in a logging helper.
- Reviewing a change that makes an existing single-type function generic.
- Replacing a hand-written generic helper with slices or maps from the standard library.
- Choosing an interface because one slice has to hold several concrete types.
More examples
A method-only constraint is an interface in disguise
The two functions have identical bodies, which is the tell: the generic one never uses T for anything except calling a method. The slice in main is what the interface version makes possible and the generic version does not, since writeGeneric is a different function for each T. Fan-out over a set of destinations is the common real case.
package main
import (
"fmt"
"io"
"os"
"strings"
)
// The wrong shape. Every method in the constraint is one this function
// calls and nothing more, so the type parameter buys nothing: it adds a
// type argument at each call and no slice can hold two writers.
func writeGeneric[T interface{ Write(p []byte) (int, error) }](w T, s string) error {
_, err := w.Write([]byte(s))
return err
}
// The right shape. io.Writer is the standard name for that method set.
func writeIface(w io.Writer, s string) error {
_, err := w.Write([]byte(s))
return err
}
func main() {
var buf strings.Builder
targets := []io.Writer{os.Stdout, &buf} // two concrete types, one slice
for _, w := range targets {
if err := writeIface(w, "hi\n"); err != nil {
fmt.Println("write failed:", err)
return
}
}
fmt.Print(buf.String())
}
// Output:
// hi
// hi
A type switch means the bodies differ
Both lines print the same thing, and the second pair needs no generics, no conversion to any and no default case that quietly returns "unknown" for types nobody thought about. When a generic body has to ask what T is, split it: two named functions, or an interface with one method that each type implements its own way.
package main
import "fmt"
// This compiles, but the type switch says the bodies are not the same,
// which is the condition generics are for. Note any(v): you cannot
// type-switch on a type parameter value directly.
func describeBad[T any](v T) string {
switch x := any(v).(type) {
case int:
return fmt.Sprintf("%d items", x)
case string:
return "name " + x
default:
return "unknown"
}
}
func describeCount(n int) string { return fmt.Sprintf("%d items", n) }
func describeName(s string) string { return "name " + s }
func main() {
fmt.Println(describeBad(3), describeBad("ada"))
fmt.Println(describeCount(3), describeName("ada"))
}
// Output:
// 3 items name ada
// 3 items name ada
What the standard library chose, and why
The clearest way to settle an argument about generics is to look at how the standard library decided the same question. Behaviour goes behind an interface; operations over a data structure take type parameters. The second habit worth forming is to check whether the helper already exists: slices and maps arrived in Go 1.21, and much hand-written generic code can simply be deleted in favour of them.
package main
import (
"fmt"
"io"
"os"
"slices"
"strings"
)
func main() {
// io.Copy takes interfaces because it only calls Read and Write:
// func Copy(dst io.Writer, src io.Reader) (written int64, err error)
n, err := io.Copy(os.Stdout, strings.NewReader("hello\n"))
if err != nil {
fmt.Println("copy failed:", err)
return
}
fmt.Println("bytes:", n)
// slices.Contains takes type parameters because its body is the same
// for every element type and it compares real values:
// func Contains[S ~[]E, E comparable](s S, v E) bool
fmt.Println(slices.Contains([]string{"a", "b"}, "b"))
}
// Output:
// hello
// bytes: 6
// true
Discussion