Skip to content
intermediate Phase 12 · Generics and the Standard Library

Generic Functions and Type Parameters

Write one function body that works for every type satisfying its constraints.

45m
0 problems
Topic Progress 0%

IndexOf, Four Times

A package needs IndexOf for []string, []int, and two domain slices. The first version is written three times with four-character differences. The fourth copy is where the bugs start: someone copies the []string version, changes the comparison, forgets one call site, and a lookup silently scans the wrong slice.

The any-based workaround compiles and loses the type check:

func IndexAny(s []any, v any) int {
	for i, x := range s {
		if x == v {
			return i
		}
	}
	return -1
}

names := []any{"ada", "grace"}
_ = IndexAny(names, 42) // compiles; will never match; no error anywhere

Callers must wrap every value in any, the compiler stops checking that the element type and the target match, and == on interface values panics at runtime when the boxed dynamic type is uncomparable — a slice or map sitting in that any turns a lookup into a crash. A type parameter fixes all three:

func IndexOf[T comparable](s []T, target T) int {
	for i, v := range s {
		if v == target {
			return i
		}
	}
	return -1
}

func Contains[T comparable](s []T, target T) bool {
	return IndexOf(s, target) != -1
}

The bracketed [T comparable] is the type parameter list. Inside the body, T behaves like an ordinary type name: it can be an element type, a parameter type, a result type, and — subject to the constraint — the operand of ==. The body is written once and checked once against the constraint, not against string or int: the compiler verifies that every operation the body uses is permitted for any type the constraint admits. Instantiating IndexOf[string] and IndexOf[int] reuses that checked body.

At the call site, the type arguments are almost always unnecessary:

i := IndexOf(names, "grace")      // T inferred as string from both arguments
j := IndexOf([]int{10, 20}, 20)   // T inferred as int
k := Contains(cookies, "session") // inference flows through a named slice type

Inference reads the ordinary arguments and solves for T. When every argument agrees, you write the call like any other function; the explicit form IndexOf[string](names, "grace") exists for the cases inference cannot cover, which the second half of this topic takes apart.

One design note that shapes everything after: Contains calls IndexOf with T — inside a generic function, a type parameter can be passed where a type is expected, so generic code composes with generic code without any extra machinery. This is the whole feature in one page: one body, static checking at every call, and no runtime dispatch.

Reading the Bracket as a Contract

The constraint is the part of the contract the compiler enforces, and there are only three shapes you will use for functions.

comparable is the built-in constraint for types that support == and !=. Slices, maps, and functions are not comparable, so IndexOf([][]int{{1}}, []int{1}) fails at the call site — the same lookup that would panic with the any version is rejected at compile time. That static rejection is the entire reason comparable exists.

any (an alias for interface{}) constrains nothing: every type qualifies. It is the right bracket when the body never inspects the value — passing it through, storing it, formatting it with %v. It is the wrong bracket the moment the body calls a method:

func Describe[T fmt.Stringer](items []T) string {
	var b strings.Builder
	for _, it := range items {
		b.WriteString(it.String()) // legal: every T has a String() method
		b.WriteByte('\n')
	}
	return b.String()
}

An interface written inside the brackets constrains by method set: only types whose method set includes String() string may be instantiated, and the body may call those methods on a T value. This differs from passing []fmt.Stringer — with the generic version, the element type survives: Describe([]User{...}) returns results you can still use as []User's values, and the caller never boxes each element into an interface. Type sets (unions like ~int | ~string) are the fourth shape, and the constraints topic takes them apart with the standard library's cmp and slices packages.

Inference has edges worth knowing by sight. It works from ordinary arguments; it fails when there is nothing to read T from:

func NewQueue[T any]() *Queue[T] { return &Queue[T]{} }

q := NewQueue()          // no arguments: nothing to infer from — error
q := NewQueue[string]()  // explicit type argument: correct

It also steps back when the argument would need conversion, or when T appears only in the result:

var zero T               // T only as a result type: specify it, e.g. zeroValue[int]()
il := IndexOf([]int{}, x) // fine — inference reads the slice's element type

And inference never widens: if one argument says int and the other says float64, there is no implicit conversion — IndexOf(floats, 1) fails because 1 would have to become a float64, and Go does not insert that conversion for you. Explicit type arguments (IndexOf[float64](f, 1)) are the escape hatch, and an untyped constant is the common reason you reach for it.

The check that ties it together: after instantiation, the call site must satisfy the constraint and the arguments must be assignable without hidden conversions. When a generic call fails to compile, read the error as one of those two questions — is T wrong for the bracket, or is an argument of the wrong type?

The Interface You Should Have Kept

A team replaces every small interface with a generic function. Six months later, a plugin system that needed runtime substitution no longer has an interface to implement, and three functions carry four type parameters each because the generic versions grew parameters the originals never had. Generics and interfaces solve different problems, and the choice is answerable before writing code.

Generics collapse code that is identical across types. The body is the same algorithm — index a slice, sort with a comparison, map values into a new slice — and the only variation is which type flows through. There is no dispatch at runtime, no value boxed into an interface, and callers keep the concrete type in their signatures:

func Map[T, U any](s []T, f func(T) U) []U {
	out := make([]U, len(s))
	for i, v := range s {
		out[i] = f(v)
	}
	return out
}

emails := Map(users, func(u User) string { return u.Email }) // []string, statically

Interfaces vary behavior at runtime. When different types implement different logic, or a value's dynamic type is only known at call time, an interface is the mechanism — it is what makes http.Handler, fmt.Stringer, and storage fakes work. A generic function cannot decide between implementations based on a value's runtime type; it was chosen at compile time. The shape renderer below needs an interface:

type Shape interface {
	Area() float64
}

func TotalArea(shapes []Shape) float64 {
	var sum float64
	for _, s := range shapes {
		sum += s.Area() // each element dispatches to its own implementation
	}
	return sum
}

[]Shape holds a circle and a rectangle in the same slice; a TotalArea[T] could only accept one element type, and mixing them would force boxing back into any.

The three-question decision. Is the logic the same for every type? Use generics. Do different types do different things, or must the choice happen at runtime? Use an interface. Is the difference one type switch on two or three cases with no shared body? A plain switch in an ordinary function beats both — generics pay a readability cost (longer errors, brackets at call sites) that one branch does not justify.

A pragmatic hybrid exists: keep the narrow consumer-side interface for boundaries where substitution matters, and write generic helpers for the data manipulation inside. The standard library itself follows this — slices.SortFunc is generic over elements while io.Writer stays an interface for streams. Where the element types and their constraints are the story rather than the plumbing, the constraints and collections topic is next.

Quiz

1. An any-based helper `IndexAny(s []any, v any)` accepts `IndexAny(names, 42)` without complaint, while `IndexOf[T comparable](names, 42)` is rejected at compile time. What does this demonstrate?

Question 1 options

2. A generic helper `func NewQueue[T any]() *Queue[T]` is called as `q := NewQueue()`. Why does it fail, and what is the fix?

Question 2 options

3. Which task is the right fit for a generic function rather than an interface?

Question 3 options

4. A call `IndexOf(floats, 1)` fails to compile even though 1 could be a float64. What is the reason?

Question 4 options

Flashcards

Question

What does `func IndexOf[T comparable](s []T, target T) int` promise the compiler?

Answer

That T supports == and != without panicking, that both arguments have the same element type, and that the body's operations are legal for every type the constraint admits. The body is checked once against the constraint; each call site is checked against the argument types.

Question

How does the compiler treat the body of a generic function, and where does instantiation come in?

Answer

It type-checks the body once using only the constraint — no call to string or int internals is visible, so anything not permitted by the constraint is rejected up front. Concrete type arguments are bound at each call site, so sharing the checked body across types costs no runtime dispatch.

Question

When do you need explicit type arguments like Queue[string]()?

Answer

When inference has nothing to read T from — no ordinary parameters, T appearing only in the result — or when arguments would need a conversion inference will not insert (an untyped int literal against a float64 element type). Otherwise the concrete type flows from the arguments.

Question

What is the practical difference between `[]fmt.Stringer` and `Describe[T fmt.Stringer](items []T)`?

Answer

The interface slice boxes every element, so concrete identity and per-element layout are lost to dynamic dispatch. The generic version keeps the concrete element type, avoids boxing, and lets callers use []User directly — while still guaranteeing, statically, that String() exists.

Question

Name the two questions that decide between a generic function and an interface.

Answer

Is the body identical for every type? — generics. Does behavior differ by type or must the choice happen at runtime? — interface. And if the variation is a two-case type switch with no shared body, a plain switch beats both.

Revision Notes

Key Takeaways

  • 1. A type parameter replaces the any-based helper: same call-site ergonomics, static element-type checking, and no panic-prone interface comparisons
  • 2. The body is checked once against the constraint — operations outside it fail before any instantiation exists
  • 3. comparable is what makes == safe; any is for bodies that never inspect the value; method constraints cover the rest
  • 4. Inference reads ordinary arguments and never inserts conversions, so empty-parameter calls and mixed literals need explicit type arguments
  • 5. Generics share code across types; interfaces vary behavior at runtime — the choice follows whether dispatch is needed

Interview Tips

  • • "What are Go generics for?" — one checked body shared by every type satisfying a constraint, and say what they are not for: runtime dispatch, which stays with interfaces
  • • "What does comparable guarantee?" — == and != are valid and cannot panic; contrast with any, where comparing boxed slices panics at runtime
  • • "When do you write the type argument explicitly?" — no arguments to infer from (NewQueue[string]()), or when a literal would need a conversion inference will not add
  • • "Generic or interface for this?" — ask whether the implementations differ per type or selection happens at runtime; if it's one shared algorithm, show the generic signature

Cheat Sheet

Generic Functions and Type Parameters

Syntax: func Name[T comparable, U any](items []T, f func(T) U) U — type parameters in brackets, used as ordinary type names in the body.

Constraint shapes for functions:

Bracket Admits Body may do
[T comparable] types supporting == and != compare, store, return
[T any] every type pass through, format, hold
[T fmt.Stringer] types with String() string call that method on T
[T ~int | ~string] (next topic) types whose underlying type matches operations the type set permits

Inference rules: T is solved from ordinary arguments; no arguments (or T only in results) means write NewQueue[string](); mixed types and untyped literals needing conversion also require explicit type arguments. Inference never inserts conversions.

Decision rule: identical body across types -> generic function; different implementations or runtime selection -> interface; two-case type switch with no shared body -> plain switch.

Code template:

func Map[T, U any](s []T, f func(T) U) []U {
	out := make([]U, len(s))
	for i, v := range s {
		out[i] = f(v)
	}
	return out
}

users := Map(raw, parseUser) // T and U inferred from the arguments

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading