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

Generic Types and Design Limits

Define generic structs and understand instantiation and constraint boundaries.

40m
0 problems
Topic Progress 0%

A Queue That Refuses Mixed Types

A worker system hands jobs between goroutines. The first version stores them as []any, and within a month a string lands in a slice that only ever receives *Job — nobody wrote a bad append; someone passed the wrong variable through an interface-shaped hole. The type system forgot the element type, and the failure surfaced as a cast panic in a scheduler loop.

A generic type puts the element type back into the declaration:

type Queue[T any] struct {
	items []T
}

func (q *Queue[T]) Enqueue(v T) {
	q.items = append(q.items, v)
}

func (q *Queue[T]) Dequeue() (T, bool) {
	var zero T
	if len(q.items) == 0 {
		return zero, false
	}
	v := q.items[0]
	q.items = q.items[1:]
	return v, true
}

func (q *Queue[T]) Len() int { return len(q.items) }

Read the receiver closely: *Queue[T] — the receiver names the same type parameter as the declaration, which is how methods gain access to T. Inside them, T is an ordinary type name: a result type, a parameter type, and the source of the zero value returned when the queue is empty. Returning zero, false rather than a bare T is the zero-value design from nil checks and zero-value safety carried into generics: there is no nil T for value types, so the boolean does the talking.

Construction has two forms, and the difference matters at call sites:

var pending Queue[*Job]        // zero value: items is nil, ready to use
pending.Enqueue(&Job{ID: 1})

seen := NewSet[string]()      // constructor call: type arguments inferred

The var form works because a slice-backed zero value is usable without initialization — the same reason plain structs avoid constructors (new vs make). A map-backed generic type needs the other path: its constructor must make the map, and NewSet[string]() writes the type argument once where inference has nothing else to read it from.

Note what the compiler now prevents: pending.Enqueue("x") is a compile error, not a later panic. The queue's invariant — every element is a *Job — is stated in the type, which is exactly what the []any version could never express.

The Method That Would Not Compile

The queue works, and a colleague adds a transform method the way generics in other languages would allow:

func (q *Queue[T]) Map[U any](f func(T) U) Queue[U] { // illegal
	var out Queue[U]
	for _, v := range q.items {
		out.Enqueue(f(v))
	}
	return out
}

The compiler's answer: method must have no type parameters. A method's receiver fixes the type parameters — here T — and there is no room to introduce more. This is a language limit, not a stylistic one, and it produces three shapes you should recognize on sight.

Generic methods are impossible. The workaround is mechanical: move the method to a free function, where the full parameter list lives:

func MapQueue[T, U any](q *Queue[T], f func(T) U) *Queue[U] {
	var out Queue[U]
	for i, v := range q.items {
		out.Enqueue(f(v))
	}
	return &out
}

The call reads MapQueue(&pending, func(j *Job) string { return j.Name }) — inference does the rest. This pattern is not a workaround unique to your code: the standard library's slices package is a shelf of free functions over slice types for precisely this reason.

You cannot specialize a method for one instantiation. Methods attach to Queue[T], so every method applies to all instantiations; there is no func (q *Queue[int]) Depth(). When one element type needs special behavior, the honest split is a free function with the concrete type — func Depth(q *Queue[int]) int — or a behavior the element type itself provides.

You cannot type-switch on T. switch v.(type) is illegal when v is a type parameter, because the compiler checks the body once against the constraint and cannot enumerate the future type arguments. Converting through any moves the decision to runtime:

func Describe[T any](v T) string {
	switch x := any(v).(type) {
	case nil:
		return "nothing"
	case fmt.Stringer:
		return x.String()
	default:
		return fmt.Sprintf("%T", x)
	}
}

That compiles because any(v) is an ordinary interface value — but notice what it signals. A generic function whose body branches on the runtime type is really an interface-shaped problem wearing generics: if the branches differ per type, an interface method expresses the design directly (the generic-vs-interface decision). Reach for any(v) conversions only at the edges, such as logging or debugging output.

Two Instantiations, One Body

What happens when Queue[int] and Queue[string] exist in the same program? They are two distinct types with no relationship to each other:

var a Queue[int]
var b Queue[string]

a = b      // illegal: Queue[string] is not assignable to Queue[int]
var x any = a
var y any = b
x == y     // false: different dynamic types, even if both are empty

No conversion exists between them, no method bridges them, and type assertions distinguish them at runtime — which is the safety working as intended. Each instantiation also gets its own zero value and its own field types: a.items is []int, b.items is []string, with all the memory-layout guarantees that concrete types have. There is no boxing, no interface header, and no per-element type switch: a Queue[int] occupies and manipulates memory exactly as a hand-written intQueue would. That is the point of writing the type once instead of twice.

The compiled-code side is worth a correct mental model. Instantiation does not copy the whole function body per type argument. The compiler groups type arguments that have the same memory layout — same size class, same pointer-ness — into one shape and compiles that shape once, so Queue[*Job] and Queue[*User] can share code while Queue[int] and Queue[[32]byte] need distinct shapes. Code size therefore grows with distinct layouts, not with distinct names, and your only real costs are compile-time checking and error-message length — both visible when a constraint violation in one package surfaces as a paragraph of type terms at the call site.

Construction rules follow ordinary struct rules with one addition. var q Queue[int] gives the zero value; Queue[int]{} fills the struct literal; a constructor NewQueue[T]() exists for types whose zero value is not usable — the map-backed case from the first chapter. Inference binds the constructor's T from the assignment or call context, but a bare Queue{} literal has nothing to infer from and needs explicit type arguments.

One place instantiation becomes visible in daily work: JSON and other serializers see the instantiated fields, not the type parameter. Queue[string] marshals as "items":[...] with strings inside, and Queue[Job] serializes whatever Job's own tags say — the generic type contributes structure, the element type contributes encoding, and neither leaks T into the output.

Designing Around the Edges

A generic type earns its place when its invariant is about storage — queue, set, pool, ring buffer — and the operations read the same for every element type. The limits from the previous chapter then shape where the code goes, and the resulting package layout has a consistent outline.

Algorithms become free functions beside the type. Map, filter, merge, drain — anything needing an extra type parameter or a second type argument lives in the same package as a function:

package queue

func Map[T, U any](q *Queue[T], f func(T) U) *Queue[U] { /* ... */ }
func Drain[T any](q *Queue[T]) []T                     { /* ... */ }
func Merge[T any](qs ...*Queue[T]) *Queue[T]           { /* ... */ }

Callers read queue.Merge(a, b) and get full inference. The alternative — growing Queue with methods that wrap a type switch — trades compile-time checking for runtime branches and should be treated as a design smell.

Behavior stays on the element type. If Job knows how to retry itself and User does not, that difference belongs on the concrete types as ordinary methods, with the generic type remaining storage. This is the division the whole phase converges on: generic functions share identical algorithms, constraints define which types may participate, and generic types hold one type's data safely — while interfaces remain the answer whenever behavior differs per value at runtime.

Watch for the pile-up. A type whose methods all take func(T) U callbacks, plus a second type parameter smuggled in through a free function, plus an any(v) type switch inside, is telling you the abstraction is fighting the language. Three signs it is time to step back: constraints leaking into the public signature where callers must write type arguments themselves, method bodies that do nothing but call a callback, and error messages that quote three packages' worth of type terms. In each case the fix is usually the older design — a small interface at the boundary, concrete types inside, and one or two generic helpers for the collection plumbing.

Used that way, generics in Go stay boring in the best sense: one declared invariant, checked at every call, no runtime cost, and a package whose public surface reads the same whether the element type is an int or a pointer to a job nobody has written yet. With the standard library and generics covered, the roadmap turns to the code that uses them — starting with an HTTP server.

Quiz

1. A colleague writes `func (q *Queue[T]) Map[U any](f func(T) U) Queue[U]` and the compiler rejects it with 'method must have no type parameters'. What is the correct restructuring?

Question 1 options

2. In one package, `Queue[int]` and `Queue[string]` are both instantiated. What is true?

Question 2 options

3. You need a method that exists only for Queue[int], not for other instantiations. What does the language allow?

Question 3 options

4. A generic log helper must print 'empty' for nil, the String() output for Stringer values, and %T otherwise. How is the type switch written?

Question 4 options

Flashcards

Question

How does a method on a generic type access the type parameter?

Answer

The receiver repeats the declaration's parameter: `func (q *Queue[T]) Enqueue(v T)` binds T for the method body, where it acts as an ordinary type name — parameter, result, and source of zero values. Methods may only use the receiver's own type parameters; introducing new ones is a compile error.

Question

What are the three generics limits that shape package design, and what is each workaround?

Answer

No generic methods -> free functions with the full type-parameter list. No per-instantiation methods -> a plain function on the concrete type or behavior on the element. No type switch or assertion on T -> convert through any(v), which moves the branch to runtime and usually means an interface was wanted.

Question

Are Queue[int] and Queue[string] assignable, comparable as a pair, or related at all?

Answer

They are distinct types: no assignment, no conversion, and through any they compare unequal because the dynamic types differ. Fields are independently typed ([]int vs []string) with no boxing — the sharing between them exists only in compiled code, grouped by memory-layout shape rather than by name.

Question

When does a generic type need a constructor instead of a usable zero value?

Answer

When an internal field must be initialized — a map field is nil in the zero value and any Add panics until make. Slice-backed and pointer-backed types keep the zero-value-usable design: `var q Queue[int]` works immediately, matching the nil-slice convention of ordinary structs.

Question

What code smells tell you a generic type has outgrown its abstraction?

Answer

Type arguments leaking into public signatures so callers must spell them, methods that only forward to a func(T) U callback, and any(v) type switches inside bodies. Each points past generics: move algorithms to free functions, put behavior on element types, or replace the abstraction with a small interface.

Revision Notes

Key Takeaways

  • 1. A generic struct's receiver repeats the type parameter, binding T for every method while keeping zero values expressible with (T, bool)
  • 2. Methods cannot introduce type parameters, cannot be specialized per instantiation, and bodies cannot type-switch on T — free functions, element methods, and any(v) are the three outs
  • 3. Distinct instantiations are distinct types with concrete, unboxed memory layouts; only the compiled code is shared, grouped by layout shape
  • 4. Map-backed generic types need constructors and make; slice-backed ones follow the zero-value-usable convention
  • 5. A generic type that accumulates callbacks, extra parameters, and any() switches is asking to become a small interface plus concrete types

Interview Tips

  • • "Implement a thread-safe generic stack" — declare type Stack[T any], methods with `(s *Stack[T])`, handle the empty case with (T, bool), and mention that synchronization comes from sync.Mutex as in any other struct
  • • "Why can't my method take its own type parameter?" — the receiver binds type parameters and methods may add none; give the free-function rewrite as the fix and note the standard library follows it
  • • "How do Queue[int] and Queue[string] relate?" — distinct types, no assignability, separate fields, shared compiled shape; no boxing either way
  • • "Generics or an interface here?" — storage with one invariant becomes a generic type, identical algorithms become free functions, and behavior that differs per value stays an interface — name which one the design needs and why

Cheat Sheet

Generic Types and Design Limits

Declaration and methods:

type Queue[T any] struct {
	items []T
}

func (q *Queue[T]) Enqueue(v T) { q.items = append(q.items, v) }

func (q *Queue[T]) Dequeue() (T, bool) {
	if len(q.items) == 0 {
		var zero T
		return zero, false
	}
	v := q.items[0]
	q.items = q.items[1:]
	return v, true
}

// algorithms needing another type parameter are free functions:
func Map[T, U any](q *Queue[T], f func(T) U) *Queue[U] { /* ... */ }

The three limits and their outs:

Limit Workaround
method must have no type parameters free function with the full [T, U any] list
no method for one instantiation only plain function on Queue[int], or logic on the element type
no type switch/assertion on T switch any(v).(type) — or an interface, if behavior really varies

Instantiation facts: Queue[int] and Queue[string] are unrelated types (no assignability); no boxing, so memory matches a hand-written concrete type; compiled once per memory-layout shape, not per name. Zero value usable for slice-backed types; map-backed types need New[T]() after make.

Design line: storage invariants -> generic type; identical algorithms -> free generic functions; differing behavior per value -> interface; concrete special cases -> methods on the element type.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading