skip to content

When Type Parameters Pay

Generics arrived late and on purpose, so a reviewer still asks what a type parameter buys over a small interface or a copied function, and since Go 1.27 a method may carry its own.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

In Go, why is `func Handle[T any](v T)` no more capable than `func Handle(v any)`?

level: middleimportance: must knowfreq 60%

answer

  1. count the positions T appears in
  2. the constraint decides what the body may do
  3. an any constraint permits no operation
  4. not even == without comparable
  5. one position ties nothing to anything

basics

~20 s

A type parameter constrained by any permits no operations — not even ==. With T appearing in only one position it links nothing to nothing, so the generic form does exactly what the plain any parameter does, with more syntax.

solid answer

~50 s

Two things make that signature empty. The constraint is `any`, so inside the body T supports nothing: no `==` (that needs `comparable`), no operators, no methods — you can copy the value and pass it on. And T appears once, as a parameter type, so it links nothing: no other parameter must match it and no result carries it back. Both together make the type parameter decoration; `Handle(v any)` is the honest signature. The rule's positive form: a type parameter pays when it *ties positions together* — `func First[E any](s []E) (E, bool)` hands the caller a `Row` back from a `[]Row` with no assertion — or when the caller picks the type that comes out, as in `func New[T any]() *T`. One difference is real but small: the generic form passes the value as its own type instead of converting it to an interface, which can avoid an allocation.

code

go · 14 lines
go
// T is used once and constrained by any, so it buys nothing.
func HandleGeneric[T any](v T) { log.Printf("%v", v) }

// Same capability, less syntax.
func Handle(v any) { log.Printf("%v", v) }

// Earns it: E ties the element type to the result type.
func First[E any](s []E) (E, bool) {
	if len(s) == 0 {
		var zero E
		return zero, false
	}
	return s[0], true
}

go deeper

for a junior

Be ready to say what a constraint of any allows inside the function: copying and passing the value, and nothing else. Recognising that a lone type parameter adds no safety is enough here.

for a middle

Explain both tests out loud — what the constraint permits, and how many positions the type parameter occupies — and give one signature that passes, such as returning an element type from a slice.

for a senior

Turn it into a review rule others can apply without you, and know the one real difference: the generic form avoids converting the value to an interface, which is an allocation claim you verify rather than assert.

for a principal

Own the consequence for exported APIs. Once a type parameter is published, removing it changes every call site, so the cost of an empty one is paid by every team that imported the package.

## Two independent tests, and this signature fails both ### Test one: what does the constraint permit? A type parameter is only as useful as its constraint. Constrained by `any`, T promises nothing about the values it stands for. Inside the function you may: - copy the value, assign it, pass it to another function that takes T or `any`; - take its address, put it in a slice or a map value. You may **not**: - compare with `==` or `!=` — that requires the `comparable` constraint; - use `<`, `+` or any other operator — that requires a constraint whose type set defines them; - call a method — that requires a constraint naming the method; - type-switch on it directly. `v.(type)` is only legal on an interface value, so you must write `switch x := any(v).(type)`, converting to an interface first. That last one is the tell. If the body's first move is `any(v)`, the type parameter has already been discarded, and the parameter should have been `any` from the start. ### Test two: how many positions does T appear in? A type parameter is a *relationship*. Its value is that two or more places in the signature are forced to agree: - `func Equal[T comparable](a, b T) bool` — the two arguments must be the same type. - `func First[E any](s []E) (E, bool)` — the result type follows from the argument's element type. - `func Map[E, R any](in []E, f func(E) R) []R` — three positions locked together. `func Handle[T any](v T)` has T in one place. Nothing is being forced to agree with anything, so nothing is gained. The one honest single-occurrence case is a type parameter in the **result**, chosen by the caller: `func New[T any]() *T`, or a decoder that returns the type you ask for. There the type parameter is not inferable and every call site writes it out — a real cost — but it does something a non-generic signature cannot: return the caller's chosen type without an assertion. ## Is there any difference at run time? Yes, in representation, not capability. `Handle(v any)` converts the argument into an interface value; for a non-pointer type that conversion may put the value on the heap. `Handle[T any](v T)` passes the value as its own type, so it can stay in the frame. If the function is hot and takes small values, that is a genuine allocation argument — but it is an argument you make with a benchmark, not with a signature. There is also a cost on the other side. Every instantiation is compiled work, and generic code carries a dictionary; a type parameter that buys nothing is not free. ## Why this shows up so often After Go 1.18 the reflex "generic is the modern way to accept anything" is common, and it produces exactly this signature. It survives review because it looks type-safe. It is not: `[T any]` gives the compiler no more information about what the body may do than `any` does — the difference is only that with `any` the loss of type information is visible in the signature, which is the honest thing to show a reader. ## The rewrite Three possible fixes, in order of preference: 1. **A concrete type.** Most functions that were made generic to "accept anything" only ever get one or two types. `func Handle(v Event)` is the best signature of all. 2. **A small interface.** If the body needs behaviour — writing, closing, stringifying — take the interface that names it: `func Handle(w io.Writer)`. That is Go's oldest and most idiomatic answer, and it lets the caller pass a mock, a wrapper or a buffer. 3. **`any`.** If the body genuinely just stores, logs or serialises the value, `any` says so honestly. A type parameter is the answer only when a relationship between types must be preserved through the call. ## Reviewing for it The check takes seconds: read the type-parameter list, then count occurrences of each parameter in the signature. One occurrence in a parameter position with an `any` constraint is a delete. It is one of the few generics review rules that needs no judgment at all.

  • Is there any real difference between the two signatures at run time?
    In representation, not capability. `Handle(v any)` converts the argument to an interface value, which for a non-pointer type may heap-allocate; `Handle[T any](v T)` passes the value as its own type and it can stay in the frame. That is an allocation argument to be measured, not a reason to prefer the shape by default.
  • How would you type-switch on the value inside `Handle[T any](v T)`?
    You convert first: `switch x := any(v).(type)`. A type-parameter value is not an interface value, so it has no dynamic type to switch on directly. Needing that conversion is a reliable sign the type parameter is doing no work and the parameter should simply be `any`.
  • What makes `func First[E any](s []E) (E, bool)` different?
    E appears twice and ties the two together: the element type of the argument determines the result type, so a `[]Row` yields a `Row` with no assertion at the call site. Preserving that relationship through the call is precisely what a type parameter is for.
  • Is a type parameter that appears only in the result always a mistake?
    No. `func New[T any]() *T` uses T once, but the caller chooses it and gets that exact type back, which no non-generic signature can do. The price is that inference cannot help, so every call site writes the type argument — worth it when the returned type genuinely varies, wasteful otherwise.

saying these in an interview costs you the question

  • Says [T any] is type-safe while any is not, without saying what T permits
  • Adds a type parameter so the signature looks like modern Go
  • Thinks a T constrained by any supports == or <
  • Cannot point to a second position where T is used
  • Writes any(v) inside the body and keeps the type parameter anyway
open as a page

In Go, what does `func Max[T cmp.Ordered](a, b T) T` buy over a version taking `any`?

level: juniorimportance: should knowfreq 52%

basics

~10 s

A type parameter keeps the caller's concrete type: Max returns T rather than any, so the compiler rejects unorderable or mismatched arguments and the caller never writes a type assertion.

open as a page

A shared Go generic pipeline helper keeps gaining type parameters and two teams copied it back into their own packages. How do you judge whether to keep it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Judge it by real call sites and by what it encodes. A helper that gains a type parameter for each new caller captures a coincidence, not a rule, so delete it and let each pipeline keep its own short loop.

open as a page

A team proposes a shared Go generics utility package. As tech lead, how do you decide?

level: principalimportance: should knowfreq 33%

basics

~20 s

Decide on ownership and cost, not elegance. Ask what slices, maps and cmp already cover, who will own and review the package, what Go version floor it imposes on importers, and how a helper gets removed.

open as a page

A Go type parameter is claimed to be faster than an interface parameter — how would you check?

level: seniorimportance: nice to knowfreq 31%

basics

~20 s

Measure both. Benchmark the two signatures with go test -bench and -benchmem, then compare the compiler's decisions from go build -gcflags=-m. Go compiles generic code per GC shape with a dictionary, so a type parameter does not automatically remove indirection.

open as a page