Generics & Advanced Types - One Function, Many Types
For most of Go's life: write a perfectly good Max for int, then need the same logic for float64, then string - identical logic, three rewrites. That's the gap generics close.
The mental model: a generic function or type leaves a type blank and fills it in later. Write the logic once, parameterized over "some type T," and the compiler stamps out a correct, type-checked version for each concrete type you use - the reuse of interface{} with none of the safety loss. This phase walks from the pain to type parameters, constraints, generic types, and finishes with the method-set rule, the one "advanced types" piece that quietly breaks code if you don't know it.
The problem generics solve
Before generics, there were exactly two ways to write "the same logic for many types," and both hurt.
Option one: copy-paste per type. Write MaxInt, MaxFloat, MaxString - identical bodies, three places for a bug to hide.
Option two: interface{} (now spelled any) and type assertions - one function, but you throw away the type information at the door and claw it back at runtime:
package main
import "fmt"
func MaxAny(a, b any) any
func main()
$ go run main.go
7
panic: interface conversion: interface {} is string, not int
What just happened: MaxAny compiled happily even though it can only handle int - any accepts anything, so the compiler can't warn you. The mistake surfaced at runtime as a panic, far from where you wrote it, and the any return type means callers must assert the result back too. Generics undo this trade: one function, but the compiler checks types before the program runs.
Type parameters - leaving a type blank
📝 Type parameter - a named stand-in for a type (T, K, V by convention), written in [...] after the function or type name. It's filled in with a real type when the code is used, and the compiler type-checks each filled-in version - usually inferring it from the call.
Here's Max, written once, working for any ordered type. The [T cmp.Ordered] says "T is some type you can compare with < and >":
package main
import (
"cmp"
"fmt"
)
func Max[T cmp.Ordered](a, b T) T
func main()
$ go run main.go
7
2.5
z
What just happened: One function, three types, zero runtime assertions. The compiler inferred T from each call and type-checked each separately - Max(3, "z") won't compile, because int and string aren't the same T. That compile-time rejection is the safety the any version threw away, handed back.
Generics shine for container-shaped helpers too - here's Map, transforming a slice of one type into another, with two type parameters:
package main
import "fmt"
// Map turns a []T into a []U by applying f to each element.
func Map[T, U any](in []T, f func(T) U) []U
func main()
$ go run main.go
[#1 #2 #3]
What just happened: Map[T, U any] has an input element type T and a different output type U. The compiler inferred T = int from the slice and U = string from the function's return, giving a properly typed []string - not []any - with no cast needed. any here means "no constraint at all," fine since Map never touches the elements except to hand them to f.
Constraints & type sets - what operations are allowed
The compiler only lets you use operations it can prove every possible T supports. A constraint is how you make that promise - it answers "what is this type allowed to do?" Two you'll use constantly:
any- no constraint. The value can be passed around, stored, compared tonil, but not much else (you can't+it or<it). This is whatMapused.comparable- the type supports==and!=. You need this for map keys and for "is this in the set?" checks.
For arithmetic or ordering, you need a constraint that permits those operators - that's where type sets come in: a constraint interface lists the concrete types it allows, and ~ means "this type or any type whose underlying type is this."
📝 Type set - the set of types a constraint permits, written as a list of types joined by | inside an interface. ~int means "int or any named type defined as int" (like type Celsius int). The allowed operations are whatever all listed types share.
package main
import "fmt"
// Number permits any integer or float, including named types like `type Age int`.
type Number interface
func Sum[T Number](nums []T) T
type Age int // underlying type is int, so ~int covers it
func main()
$ go run main.go
6
4
70
What just happened: Number defines a type set - int, int64, or float64 - and Sum may use + only because every type in that set supports it. ~int (rather than plain int) is what lets Age, a named type whose underlying type is int, slip through; without it, only the literal type int would qualify. A constraint is "which operations the compiler will let your generic code perform," not "which types fit."
Generic types - a typed container, written once
Type parameters aren't only for functions - a struct can be generic too, letting you build a Stack, Set, or Cache that holds one specific type without resorting to []any:
package main
import "fmt"
type Stack[T any] struct
func (v T)
func () (T, bool)
func main()
$ go run main.go
b true
empty pop ok? false
What just happened: Stack[T any] is a generic struct; its methods carry [T] so they know which type they're operating on. Declaring Stack[string] locked T to string - s.Push(42) would be a compile error. The var zero T trick in Pop gives you a zero value ("", 0, nil) for an unknown type, returned alongside false - the idiomatic way to signal "nothing there" without panicking.
Method sets & receivers - the rule that bites
Now the "advanced types" piece that surprises people, and it's not about generics - it's about which methods count toward satisfying an interface, depending on whether a method has a value receiver or a pointer receiver.
📝 Method set - the set of methods a type "has" for the purpose of satisfying interfaces. For a value type T, the method set is only its value-receiver methods. For a pointer *T, the method set is both its value-receiver and pointer-receiver methods.
The rule that catches everyone: if a method has a pointer receiver, only *T satisfies the interface - not T.
package main
import "fmt"
type Speaker interface
type Dog struct
// Pointer receiver - only *Dog will satisfy Speaker.
func () string
func main()
$ go run main.go
Rex says woof
What just happened: Speak has a pointer receiver (d *Dog), so only *Dog is in the method set satisfying Speaker. A plain Dog value fails to compile with Dog does not implement Speaker (method Speak has pointer receiver). Why the asymmetry? Go can take the address of an addressable value to call a pointer method, but can't guarantee an arbitrary interface-held value is addressable - so it refuses at the safe boundary.
⚠️ Gotcha - pointer receiver, value passed. The canonical Go interface bug: you define methods with pointer receivers (correct if they mutate state or the struct is large), then pass the value into something expecting the interface - a []Speaker, a function parameter, a json.Marshaler slot - and get a confusing "does not implement" error. The fix is almost always &thing instead of thing. Reverse direction is fine: value-receiver-only methods satisfy the interface as both T and *T.
💡 Key point - generics or an interface? Use generics for the same logic over many types - Max, Map, Stack: the body never changes, only the type does. Use an interface for different logic behind shared behavior - a Writer that's a file vs. buffer vs. socket, each Write doing something genuinely different. Same code, varying type → generic; varying code, same call → interface.
Recap
- Generics solve duplication without losing safety - before them you copy-pasted per type or used
any+ assertions (which moved errors to runtime). Type parameters keep one function and keep compile-time checking. - Type parameters go in
[...]after the name (func Max[T cmp.Ordered](...),Map[T, U any]); the compiler usually infers the concrete type from the call. - Constraints define allowed operations.
any= no operations beyond passing around;comparable===/!=; custom type sets (~int | ~float64) permit only the operations all listed types share. The~admits named types with that underlying type. - Generic types like
Stack[T]give you typed containers written once; usevar zero Tto produce a default value for an unknown type. - ⚠️ Method sets: a pointer-receiver method means only
*Tsatisfies the interface, notT. Pass&thing, notthing, into interface slots when methods have pointer receivers. - 💡 Generics vs. interfaces: same logic over many types → generics; different logic behind a shared method → interface.
You can now write code reused across types without giving up the compiler's help, and you know the method-set rule that otherwise turns a one-character fix into an hour of confusion. Next: goroutines and channels, pulled together into real concurrency patterns.
Quick check
Test yourself on the two ideas most likely to bite - constraints and method sets:
[
{
"q": "Why does the `any` version of `Max` compile even though it only handles `int`, while the generic version catches the mistake?",
"choices": [
"`any` accepts any value, so the compiler can't check the types - the error only appears at runtime as a panic",
"`any` is slower, so the compiler skips type checking to save time",
"The generic version also fails at runtime; there's no real difference",
"`any` automatically converts strings to ints before comparing"
],
"answer": 0,
"explain": "An `any` parameter accepts everything, so the compiler has no type information to verify against - bad calls compile and panic at runtime. A type parameter ties the arguments to one concrete `T` the compiler checks before the program runs."
},
{
"q": "In the constraint `~int | ~float64`, what does the `~` add?",
"choices": [
"It admits named types whose underlying type is int or float64 (like `type Age int`), not just the literal types",
"It makes the constraint match all numeric types automatically",
"It marks the types as optional, so any type at all is allowed",
"It enables approximate (floating-point) comparison"
],
"answer": 0,
"explain": "`~int` means 'int, or any named type whose underlying type is int.' Without the `~`, only the literal type `int` qualifies and a `type Age int` would be rejected."
},
{
"q": "`Speak()` has a pointer receiver `(d *Dog)`. Which satisfies the `Speaker` interface?",
"choices": [
"Only `*Dog` - a plain `Dog` value does not implement Speaker",
"Only `Dog` - pointer receivers don't count toward interfaces",
"Both `Dog` and `*Dog`, always",
"Neither - interfaces require value receivers"
],
"answer": 0,
"explain": "A pointer-receiver method is only in the method set of `*T`, so only `*Dog` satisfies the interface. Pass `&Dog{...}`, not `Dog{...}`. (The reverse holds for value receivers: those satisfy via both `T` and `*T`.)"
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. Why does the `any` version of `Max` compile even though it only handles `int`, while the generic version catches the mistake?
2. In the constraint `~int | ~float64`, what does the `~` add?
3. `Speak()` has a pointer receiver `(d *Dog)`. Which satisfies the `Speaker` interface?