While learning Go, I kept running into struct{}. First as a way for memory-efficient sets, since it takes zero bytes, you can use a map[T]struct{} as a set without wasting any space on values. Then again with goroutines, where it’s the standard way to send a signal over a channel.
Both made sense to use. What I didn’t fully understand was: how does something that takes zero bytes actually work under the hood?
Spoiler: the answer is in the runtime. Let’s dig in.
Intro
First, let’s confirm it actually takes zero bytes. We can measure this with unsafe.Sizeof, which returns the size of a type in bytes — not how many elements it holds, but how much memory the type itself occupies. That’s different from len, which tells you the number of elements in a collection.
var s struct{}
fmt.Println(unsafe.Sizeof(s)) // 0
arr := [1000000000000000]struct{}{}
fmt.Println(unsafe.Sizeof(arr)) // 0
sl := make([]struct{}, 1000000000000000)
fmt.Println(unsafe.Sizeof(sl)) // 24 (slice header only)
The single value and the array both report 0 bytes. The slice reports 24 — but that’s just the slice header (pointer + length + capacity), not the elements. A trillion struct{} values take up no memory at all.
You’ve learned Go. You can write a for loop, define a struct, maybe spin up an HTTP server. Then you open the Kubernetes source, see func ToPtr[T any](v T) *T, and your brain stalls.
This post is the bridge. By the end you’ll be able to read generic Go code in real codebases — Kubernetes especially — without flinching.
The 60-second mental model
Before generics (pre-Go 1.18), if you wanted a function that worked on any type, you had two bad options:
- Write the same function ten times, once per type (
MaxInt,MaxFloat64, …). - Use
interface{}everywhere and lose all type safety.
Generics give you a third option: write the function once, but tell the compiler “this works for any type T that satisfies these rules.” The compiler then checks each call site for you.
That’s it. Generics are parameterized types — same idea as function parameters, but for types instead of values.
The syntax you need to recognize
Here’s a generic function:
func ToPtr[T any](v T) *T {
return &v
}
Three new things compared to regular Go:
[T any]— the type parameter list.Tis a placeholder for whatever type the caller uses.any— the constraint. It means “literally any type.” (anyis just an alias forinterface{}.)v Tand*T—Tis now usable in the signature like any normal type.
Calling it:
p := ToPtr(42) // T inferred as int → *int
s := ToPtr("hello") // T inferred as string → *string
You usually don’t have to write ToPtr[int](42) — the compiler infers T from the argument.
Constraints: the gatekeeper
any is permissive. Often you want to say “T must support ==” or “T must be a number.” That’s what constraints are.
func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
comparable is a built-in constraint that means “supports == and !=.” Without it, the v == target line wouldn’t compile.
You can also define your own constraints as interfaces:
type Number interface {
int | int64 | float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
The int | int64 | float64 is a type union — T must be one of these.
Generics in the wild: Kubernetes
Kubernetes adopted generics carefully, mostly in utility packages. Let’s read three real examples.
1. ptr.To — the pointer helper
From k8s.io/utils/ptr:
func To[T any](v T) *T {
return &v
}
Before generics, Kubernetes had pointer.Int32(5), pointer.StringPtr("x"), pointer.BoolPtr(true) — a separate function per type. Now it’s one function. You’ll see this everywhere in Kubernetes:
replicas := ptr.To(int32(3))
enabled := ptr.To(true)
2. sets.Set[T] — generic sets
From k8s.io/apimachinery/pkg/util/sets:
type Set[T comparable] map[T]Empty
func New[T comparable](items ...T) Set[T] {
ss := Set[T]{}
ss.Insert(items...)
return ss
}
Two things to notice:
Set[T comparable]— a generic type. Same syntax as functions, but on a type definition.- The constraint is
comparablebecause map keys in Go must be comparable.
In use:
nodes := sets.New[string]("node-1", "node-2", "node-3")
if nodes.Has("node-1") { /* ... */ }
3. slices.Contains — from the standard library, used heavily in k8s
func Contains[S ~[]E, E comparable](s S, v E) bool
This one has two type parameters. The ~[]E is a new piece of syntax — the ~ means “any type whose underlying type is []E.” That lets it work on named slice types too, like type NodeList []Node.
Don’t get hung up on ~ on your first read. Just know: it makes the function work on slice-like named types, not only raw []E.
When NOT to use generics
Beginners often go generics-crazy after learning them. Don’t. The Go team’s own advice:
- If a regular function works, use a regular function.
- If an interface works, use an interface.
- Reach for generics when you’d otherwise be writing the same function multiple times for different types — like
ptr.Toor container types (sets, queues, trees).
A good smell test: if your generic function only ever has one call site, it probably shouldn’t be generic.
Wrap-up
You now have enough to read 90% of the generic code in Kubernetes:
[T any]— placeholder type, no restrictions[T comparable]— supports==[T SomeInterface]— must satisfy that interface (possibly a type union)~[]E— any named type whose underlying type is[]E
Next time you’re spelunking through k8s.io/utils or apimachinery, you’ll see these patterns repeat. The syntax is small. The hard part — knowing when to use it — comes later.
Open a Kubernetes file. Find a [T in the source. Read it. You’ll be fine.