Generics
Go added generics in 1.18, and they are deliberately narrow compared to C++ templates, Rust traits, or C# generics: no specialization, no method-level type parameters, no variance, and the only thing a constraint can express is a type set. If you come from one of those languages, your instinct will be slightly wrong in two or three specific places — and this chapter points right at them.
We’ll learn by reading real code. The multigres codebase (“Vitess for Postgres”) uses generics in a focused way — type-safe containers, “same algorithm over many element types” helpers, and config plumbing — so the examples are small and dissectable. Builds on Types, Structs & Methods (methods, receivers, zero values), Interfaces & Composition (interfaces as method sets), and Errors (the (T, error) return shape).
Type parameters on functions, and inference
Section titled “Type parameters on functions, and inference”A generic function declares its type parameters in square brackets after the name, before the ordinary parameter list. Each type parameter has a constraint — here any, which means “no constraint at all.”
func First[T any](s []T) (T, bool) { if len(s) == 0 { var zero T // can't write `return nil` or `return 0` — T is unknown return zero, false } return s[0], true}
// Caller writes no type arguments; T is inferred from the slice's element type.x, ok := First([]int{1, 2, 3}) // T = intTwo things to absorb immediately. First, T any is the same as T interface{} — any is just the predeclared alias. Second, that var zero T line: inside a generic function you don’t know whether T is a pointer, a number, or a struct, so you can’t spell its “empty” value literally. var zero T gives you the zero value for whatever T turns out to be — nil for pointers/interfaces/maps/slices, 0 for numbers, false for bool, the all-fields-zeroed value for a struct. This is the idiom newcomers stumble on, and you’ll see it everywhere below.
Real usage: a connection-pool retry helper
Section titled “Real usage: a connection-pool retry helper”The hot query path multigateway -> multipooler -> postgres wraps every backend operation in a generic helper, so retry-on-connection-error logic is written once and reused across operations that return wildly different result types.
// retryOnConnectionError executes op with automatic retry on connection error.func retryOnConnectionError[T any](c *Conn, ctx context.Context, op func() (T, error)) (T, error) { for attempt := 1; attempt <= constants.MaxConnPoolRetryAttempts; attempt++ { val, err := execOnce(c, ctx, op) switch { case err == nil: return val, nil case !mterrors.IsConnectionError(err): var zero T return zero, err case attempt == constants.MaxConnPoolRetryAttempts: c.conn.Close() var zero T return zero, err } // ... backoff + reconnect ... } panic("unreachable")}The companion execWithContextCancel[T any] and execOnce[T any] in the same file have an identical shape: they take op func() (T, error), run it on a goroutine, and select on context cancellation versus completion. Every error path returns var zero T, err.
Why generics here and not interface{}? Without generics the helper would have to return (interface{}, error), and every caller would type-assert the result — paying a boxing allocation and a runtime cast that can panic. With [T any] the return type is the exact type the caller wants, checked at compile time.
Now look at inference at the call sites. The caller writes no type arguments; T is deduced from the return type of the function literal passed as op:
// T = []*sqltypes.Resultreturn execWithContextCancel(c, ctx, func() ([]*sqltypes.Result, error) { return c.conn.Query(ctx, sql)})
// T = boolreturn execWithContextCancel(c, ctx, func() (bool, error) { return c.conn.BindAndExecute(ctx, /* ... */)})
// T = struct{} — the op has no meaningful return value_, err := execWithContextCancel(c, ctx, func() (struct{}, error) { return struct{}{}, c.conn.QueryStreaming(ctx, sql, callback)})The struct{} cases are an idiom worth naming: when an operation is “fire and check the error” with no payload, the author still has to give T some type, so they pick the empty struct (zero bytes, allocates nothing) and discard the value with _,.
Type parameters on types
Section titled “Type parameters on types”A type can carry its own type parameters. They’re in scope on every field and every method of that type.
type Box[T any] struct { value T}
func (b *Box[T]) Get() T { return b.value } // method reuses the receiver's Tfunc (b *Box[T]) Set(v T) { b.value = v }Note the receiver is written (b *Box[T]), not (b *Box). You repeat the type parameter name so the body can refer to T. The method does not re-declare T as new — it reuses the one bound when the value was created. (Methods cannot introduce their own type parameters; that surprises everyone, so it gets its own section below.)
Real usage: a two-parameter store
Section titled “Real usage: a two-parameter store”One of the services keeps an in-memory, clone-on-access store of protobuf health/topology state. It’s generic over both the key and the value:
type ProtoStore[K comparable, V proto.Message] struct { mu sync.Mutex items map[K]V}
func (s *ProtoStore[K, V]) Get(key K) (V, bool) { s.mu.Lock() defer s.mu.Unlock()
v, ok := s.items[key] if !ok { var zero V return zero, false } cloned := proto.Clone(v).(V) // legal because V is constrained to proto.Message return cloned, true}Two constraints carry real weight here:
K comparable—Kis used as a map key (items map[K]V). Map keys must support==, andcomparableis exactly the constraint that promises that. You couldn’t writeitems map[K]VifKwere merelyany.V proto.Message— constrainingVto the third-party interfaceproto.Messagelets the body callproto.Clone(v).proto.Clonereturnsproto.Message, so.(V)asserts it back to the concrete value type. Because everyVsatisfiesproto.Message, this assertion always succeeds for values that actually came out of this store.
Every method — Get, Set, Range, DoUpdate, Delete, Len, Clear — is written func (s *ProtoStore[K, V]) ..., reusing the receiver’s K and V. The author chose a custom struct over sync.Map deliberately: the recovery engine is write-heavy, which defeats sync.Map’s read-optimized design.
The constraint taxonomy
Section titled “The constraint taxonomy”A constraint is an interface used as a type set — the set of types a type parameter is allowed to be instantiated with. There are four shapes you’ll meet in this codebase.
any — no constraint
Section titled “any — no constraint”Already seen: [T any]. No operations on T are permitted except those valid for every type (assignment, passing around, var zero T).
comparable — supports ==, !=, and map keys
Section titled “comparable — supports ==, !=, and map keys”A built-in constraint, required whenever T is a map key or you use ==/!= on it. The gateway uses it to model PostgreSQL SET / SET LOCAL / SAVEPOINT semantics for a single session variable:
type GatewayManagedVariable[T comparable] struct { defaultValue T currentValue T isSet bool localValue T isLocalSet bool snapshots []gmvSnapshot[T]}T is comparable so the handler can compare config values, and the methods use var zero T to clear them.
cmp.Ordered — supports <, <=, >, >=
Section titled “cmp.Ordered — supports <, <=, >, >=”From the standard-library cmp package (Go 1.21+). This is the constraint you need when you want to sort or order, which comparable alone cannot do.
The cleanest teaching pair in the repo lives in a small sortedmaps package, which exists so map iteration produces deterministic output (Go randomizes range over maps). Keys constrains to cmp.Ordered so it can call slices.Sort:
func Keys[K cmp.Ordered, V any](m map[K]V) []K { keys := make([]K, 0, len(m)) for k := range m { keys = append(keys, k) } slices.Sort(keys) // needs <, hence cmp.Ordered, not just comparable return keys}Right below it, ByStr handles the case where the key is comparable but not orderable:
// ByStr ... Use this when K is comparable but not cmp.Ordered ...func ByStr[K comparable, V any](m map[K]V) iter.Seq2[K, V] { keys := make([]K, 0, len(m)) for k := range m { keys = append(keys, k) } slices.SortFunc(keys, func(a, b K) int { return strings.Compare(fmt.Sprintf("%v", a), fmt.Sprintf("%v", b)) }) // ... yield in that order ...}This is an in-repo demonstration of why you pick one constraint over the other: Keys/Values/All need ordering and accept the stronger cmp.Ordered; ByStr only promises comparable and falls back to comparing the fmt.Sprintf("%v", ...) string form. (All returns iter.Seq2[K, V], a Go 1.23 range-over-func iterator — covered in the stdlib chapter.)
Union / type-set constraints (|)
Section titled “Union / type-set constraints (|)”An interface can list concrete types separated by |. A type parameter constrained by it may be any one of those types.
type SignedSmall interface { int8 | int16 | int32 | int64}func Abs[T SignedSmall](x T) T { if x < 0 { return -x }; return x }The only union constraints in the codebase are inline exact-type unions in the config plumbing, which cast viper’s wide getters down to narrow numeric types:
func getCastedInt[T int8 | int16]() func(v *viper.Viper) func(key string) T { return func(v *viper.Viper) func(key string) T { return func(key string) T { return T(v.GetInt(key)) // legal: every member of the set is convertible from int } }}
func getCastedUint[T uint8 | uint16]() func(v *viper.Viper) func(key string) T { /* ... */ }func getComplex[T complex64 | complex128](bitSize int) func(v *viper.Viper) func(key string) T { /* ... */ }Custom interfaces as constraints — interfaces wear two hats
Section titled “Custom interfaces as constraints — interfaces wear two hats”This is the key conceptual link to interfaces: an interface is both a runtime value type (dynamic dispatch) and a compile-time constraint (a type set). The connection pool uses one interface for both jobs.
It first defines an ordinary method-set interface:
type Connection interface { Settings() *connstate.Settings IsClosed() bool Close() error ApplySettings(ctx context.Context, desired *connstate.Settings) error ResetAllSettings(ctx context.Context) error}Then it uses that same interface as a generic constraint:
type Connector[C Connection] func(ctx context.Context, poolCtx context.Context) (C, error)
type Pool[C Connection] struct { clean connStack[C] states [stackMask + 1]connStack[C] // array of generic-typed stacks wait waitlist[C] // ...}Why constrain C to Connection instead of using Connection directly as the field type? Two reasons. First, a Pool[*regularConn] stores and hands back *regularConn values with no interface boxing and no type assertions — the pool is monomorphized to the concrete connection type at compile time. Second, the constraint still guarantees the pool can call c.Close(), c.Settings(), etc., because every C is a Connection. You get interface-level guarantees with concrete-type performance — exactly the trade generics exist to make on a latency-sensitive path.
A constraint mixing comparable with methods
Section titled “A constraint mixing comparable with methods”A constraint can embed comparable and require methods. A cache does this for its key type:
type cachekey interface { comparable Hash() uint64 Hash2() (uint64, uint64)}
type cacheval interface { CachedSize(alloc bool) int64}
type Store[K cachekey, V cacheval] struct { // ... shards []*Shard[K, V]}The two concrete key types that satisfy cachekey live in the same file: a [32]byte hash key and a string key, each providing Hash/Hash2. cachekey embeds comparable because K is used as a map key inside each shard (hashmap map[K]*Entry[K, V]). It also requires the hash methods because the sharding and bloom-filter logic call key.Hash(). Drop the embedded comparable and the map[K]... field stops compiling; drop the methods and the sharding code stops compiling.
Explicit instantiation — when inference cannot help
Section titled “Explicit instantiation — when inference cannot help”Generic functions can usually infer their type arguments. Generic types never can — there’s nothing to infer from when you name a type, so you must always supply the arguments in brackets.
// Generic type — explicit args are mandatory, there's no value to infer from:b := Box[int]{value: 7}
// Generic function — args usually inferred, but you may write them when needed:x, _ := First[string](nil) // explicit because nil gives no element type to inferReal usage: Options[T] literals
Section titled “Real usage: Options[T] literals”Config call sites instantiate the generic Options[T] struct explicitly, then let Configure[T any] infer T from that argument:
Enabled: viperutil.Configure(reg, "buffer-enabled", viperutil.Options[bool]{ Default: false, FlagName: "buffer-enabled", EnvVars: []string{"MT_BUFFER_ENABLED"},}),Window: viperutil.Configure(reg, "buffer-window", viperutil.Options[time.Duration]{ Default: 10 * time.Second, /* ... */}),Size: viperutil.Configure(reg, "buffer-size", viperutil.Options[int]{ Default: 1000, /* ... */ }),The Options[bool]{...} part must be written explicitly — it’s a generic type literal. But viperutil.Configure(reg, key, opts) does not need [bool] written: its signature Configure[T any](reg *Registry, key string, opts Options[T]) Value[T] infers T from the Options[T] argument’s type. So you get a mix on a single line: explicit on the type, inferred on the function.
The other explicit-instantiation flavor lives inside GetFuncForType[T any], which switches on reflect.Kind and instantiates the small-numeric helpers by hand, because there’s no value to infer from in a reflect switch:
case reflect.Int8: f = getCastedInt[int8]()case reflect.Int16: f = getCastedInt[int16]()// ...case reflect.Uint8: f = getCastedUint[uint8]()Generic interfaces and the satisfaction-assertion idiom
Section titled “Generic interfaces and the satisfaction-assertion idiom”Interfaces can be generic too:
type Value[T any] interface { value.Registerable // embedded non-generic interface Get() T Set(v T) Default() T}Just above it is a compile-time interface-satisfaction check, written with explicit instantiation:
var ( _ Value[int] = (*value.Static[int])(nil) _ Value[int] = (*value.Dynamic[int])(nil))The pattern var _ Iface = (*Impl)(nil) asserts at compile time that *Impl satisfies Iface, with zero runtime cost (the value is the blank identifier). With generics you simply instantiate both sides at a concrete type (int here) to perform the check. If value.Static[int] ever stopped implementing Value[int], the build would fail at this line rather than at some distant call site.
Nested instantiation
Section titled “Nested instantiation”A generic type can be parameterized by another generic type. The waitlist stacks generics two deep:
type waitlist[C Connection] struct { nodes sync.Pool mu sync.Mutex list list.List[waiter[C]] // List instantiated at element type waiter[C]}Here waiter[C] is itself a generic type, used as the type argument to list.List[...]. Inside the methods you get values of type *list.Element[waiter[C]]:
elem := wl.nodes.Get().(*list.Element[waiter[C]])C flows from the enclosing waitlist’s own type parameter all the way into List and Element. This is ordinary, but it reads densely the first time; the key is that [C] is just a name being threaded through.
Third-party generic types
Section titled “Third-party generic types”External generic libraries are consumed the same way as the codebase’s own. The cache’s per-shard recency queue is a github.com/gammazero/deque:
import "github.com/gammazero/deque"
type Shard[K cachekey, V any] struct { // ... deque *deque.Deque[*Entry[K, V]] // element type is the generic *Entry[K,V]}
// inside NewShard:deque: &deque.Deque[*Entry[K, V]]{},When Store is instantiated as, say, Store[StringKey, *someval], the deque’s element type becomes *Entry[StringKey, *someval]. The cache then calls shard.deque.PushFront(entry) and shard.deque.PopBack(). Both are fully type-checked: PushFront accepts only *Entry[K, V], and PopBack returns *Entry[K, V] with no cast and no interface{} boxing. That type safety with zero assertions is the entire payoff of the library being generic rather than a container/list-style interface{} container.
A first-party generic container
Section titled “A first-party generic container”The codebase also keeps a generics fork of container/list. Its package doc states the motive plainly:
// Package list is the standard library's 'container/list', but using Generics// for performance.
type Element[T any] struct { next, prev *Element[T] list *List[T] Value T}
type List[T any] struct { root Element[T] len atomic.Int64}
func New[T any]() *List[T] { return new(List[T]).Init() }func (l *List[T]) Front() *Element[T] { /* ... */ }The stdlib container/list stores interface{} in Element.Value; this fork stores T. For the connection pool’s waitlist, that means no boxing of waiter[C] values on a per-borrow basis — relevant because the pool sits on the latency-sensitive query path.
The rule that bites everyone: methods can’t have their own type parameters
Section titled “The rule that bites everyone: methods can’t have their own type parameters”In C#, Rust, and TypeScript you can put a type parameter on an individual method. Go forbids this. A method may reuse the receiver’s type parameters but may not introduce new ones.
func (b *Box[T]) Map[U any](f func(T) U) U { // error: methods cannot have type parameters return f(b.value)}The language designers excluded method type parameters partly because they’d interact badly with interface satisfaction — an interface method set with open type parameters has no finite shape. Concretely: you couldn’t add func (s *ProtoStore[K, V]) GetAs[U any](key K) U to ProtoStore; the compiler rejects the [U any] on the method outright.
The workaround the codebase uses everywhere is to make the operation a top-level generic function instead of a method:
sortedmaps.Keys[K cmp.Ordered, V any](m map[K]V)— a free function, not a method on some map wrapper.prototest.RequireElementsMatch[T proto.Message](t *testing.T, expected, actual []T, ...)— a free generic test helper, not a method.- The connpool retry helpers
retryOnConnectionError[T any]/execWithContextCancel[T any]take the*Connas an ordinary first argument, precisely so each call can pick its ownT.
func RequireElementsMatch[T proto.Message](t *testing.T, expected, actual []T, msgAndArgs ...any) { t.Helper() if len(expected) != len(actual) { /* ... */ } // ... order-insensitive proto.Equal comparison ...}Generics vs interfaces vs codegen
Section titled “Generics vs interfaces vs codegen”These three tools overlap, and a real system uses all of them — each where it fits.
| Use | Reach for it when | What you get |
|---|---|---|
| Generics | You want a type-safe container or one algorithm over many element types, with no runtime polymorphism | No interface{} boxing, no casts; everything monomorphized at compile time. Pool[C], ProtoStore[K, V], retryOnConnectionError[T]. |
| Interfaces | You need runtime polymorphism — heterogeneous values dispatched through one method set, concrete type genuinely varying at runtime | Dynamic dispatch. Connection plays this role as a value type. (The clever bit: one interface can serve as a constraint too.) |
| Codegen | The transformation can’t be expressed as “the same code over different types” at all | Generated code per concrete type. Whole-AST clone/rewrite, generated protobuf message types. |
The codegen line is worth dwelling on. Walking every concrete node type in an AST to clone or rewrite it can’t be a generic: there’s no single type parameter that captures “for each field of each of these many unrelated struct types, recurse appropriately.” So the parser’s helper tool generates the clone/rewrite code by reflecting over the set of AST node structs, and protoc generates the message types. See Parser, Lexer, AST & Codegen.
Checkpoints
Section titled “Checkpoints”-
Inside
retryOnConnectionError[T any], why can’t the error paths justreturn nil, err, and what do they write instead?Answer
BecauseTis unknown at definition time and not everyThas anil(e.g.T = bool). Every error path declaresvar zero Tand returnszero, err.var zero Tyields the correct empty value for whateverTis at the instantiation site. -
sortedmaps.Keysis constrained tocmp.OrderedbutByStronly tocomparable. What capability does the stronger constraint buy, and how doesByStrcope without it?Answer
cmp.Orderedadds</<=/>/>=, whichslices.Sortneeds.comparableonly promises==/!=, soByStrcan’t callslices.Sort; it usesslices.SortFunccomparingfmt.Sprintf("%v", key)string forms instead. -
Why is
var _ Value[int] = (*value.Static[int])(nil)written inviperutil/value.go, and what does it cost at runtime?Answer
It’s a compile-time assertion that*value.Static[int]satisfies the generic interfaceValue[int]. The value is assigned to the blank identifier, so it costs nothing at runtime; if the implementation ever stopped satisfying the interface, the build would fail at this line. -
You want to add
func (s *ProtoStore[K, V]) Decode[U any](key K) U. Will it compile? What’s the idiomatic fix?Answer
No — Go forbids methods from declaring their own type parameters ([U any]on a method is illegal). The fix is a top-level generic function that takes the store as its first argument, e.g.func Decode[K comparable, V proto.Message, U any](s *ProtoStore[K, V], key K) U, mirroring howprototest.RequireElementsMatchand the connpool retry helpers are free functions.
Exercises
Section titled “Exercises”-
In
go/services/multipooler/internal/pools/regular/regular_conn.go, list every distinct concrete type thatTis inferred as at theexecWithContextCancelcall sites. For the two that usestruct{}, explain what that signals about the underlying operation. -
Open
go/tools/sortedmaps/sortedmaps.go. In your own words, justify whyKeys/Values/Alltakecmp.OrderedwhileByStrtakescomparable. Then find a type used as a map key elsewhere that’scomparablebut notcmp.Ordered(hint: look at the cache’s key types), and argue which helper you’d use to iterate a map keyed by it. -
Find every occurrence of
var zero T/var zero Vin the multiorch store and the connpool regular-conn file. For each, state the concrete zero value produced when the parameter is (a) a pointer, (b)bool, (c) a struct. -
Locate the
cachekeyconstraint and the two concrete types that satisfy it. Explain whycachekeyembeds the built-incomparablein addition to itsHash/Hash2methods, and name the exact field declaration that would stop compiling if thecomparableembedding were removed. -
Trace one instantiation of the third-party deque. Starting from
deque *deque.Deque[*Entry[K, V]], work out the element type whenStoreis instantiated asStore[StringKey, *someval]. Find thePushFrontandPopBackcalls and confirm there are no type assertions on the way in or out.