Generics

Use type parameters, constraints, and type sets to write reusable, type-safe Go, without common named-type, inference, and method mistakes.

level intermediate time 11 min at Standard depth
version Go 1.27
what

Generics let functions and types declare type parameters, so one implementation can handle a set of concrete types while preserving static relationships between inputs and outputs.

trap

A constraint isn’t a label; it determines which operations the compiler permits in generic code. comparable allows only == and !=, not <.

fix

Work backward from the operations the implementation needs to the smallest useful constraint. Put ~ before a type term when named types with the same underlying type should qualify.

What it is and why it exists

A generic is a function or type with one or more type parameters. A concrete type replaces each parameter when you call a generic function or instantiate a generic type. The compiler still checks operations, arguments, and results at compile time, so callers don’t have to assert a value from any back to the desired type.

Generics address code that repeats across types while keeping the same structure. Slice transformations, set lookups, and stacks care about relationships between element types, but shouldn’t need separate implementations for int, string, and every domain type. Type parameters put that relationship in the signature: Map[T, U any]([]T, func(T) U) []U says that the converter accepts an element from the input and determines the result element type.

Interfaces and generics solve different problems. An interface value hides a dynamic concrete type and is useful when callers operate on different implementations through a method set; a generic preserves the concrete static type within each instantiation and suits algorithms and containers. If an implementation only calls a method such as Read or String, an ordinary interface parameter is often more direct. If the same type appears in several parameter or result positions, a type parameter usually expresses the contract better.

Go added generics in 1.18. You’ll meet them in the standard library’s slices, maps, and cmp packages, in reusable data structures, and in helpers that would otherwise require reflection. They aren’t a way to remove all duplication: separate implementations are clearer when types require different semantics.

How it works

Type parameters preserve relationships

A type parameter appears in square brackets after a declaration’s name. T, K, and V are type names used within that declaration, and every name has a constraint. Adjacent parameters with the same constraint can share it, as in [T, U any].

Ordinary parameters and results of a generic function can use these names. Fields of a generic type can use them too, while methods redeclare the corresponding receiver type parameters in the receiver, as in func (s *Stack[T]) Push(value T). This T corresponds to the parameter of Stack; it isn’t a new type parameter introduced by the method.

Instantiation performs two checks. The compiler first confirms that every type argument satisfies its constraint, then creates the function or type instance for those arguments. The resulting Stack[string] and Stack[int] are different named types and aren’t assignable to one another.

Constraints determine available operations

A generic constraint is an interface. It describes the type set allowed to replace a type parameter and determines what the function body may do with that parameter. With an any constraint, code can rely only on behavior common to every type, such as assignment, passing, and returning values.

The predeclared comparable constraint accepts types that can be map keys and permits == and !=. It promises no ordering, so a < b doesn’t compile. For ordered operations, use the standard library’s cmp.Ordered or declare a constraint limited to the types your domain needs.

A constraint interface can embed methods, type terms, or both. fmt.Stringer requires a String() string method; ~int | ~int64 is a union accepting types whose underlying type is int or int64. Separate elements in a constraint intersect, so a type must meet both sides when you write a method and a type term.

Type sets describe candidates

A type set is the set of all non-interface types represented by an interface. A method element keeps types implementing that method, a union combines candidate type terms, and separate embedded elements intersect. Generic code may use only operations supported with the same meaning by every type in the set.

The type term int means only the predeclared type int. ~int also includes a named type such as type Score int, because its underlying type is int. The tilde isn’t approximate runtime matching; it is constraint syntax checked statically by the compiler.

An interface containing type terms can be used only as a constraint, not as an ordinary variable type. You can write func Max[T Ordered](...), but you can’t declare var value Ordered to store any ordered value. If you need an interface value, use a basic interface containing only methods, or use any and validate the dynamic type at a boundary.

Type inference removes call-site noise

Type inference lets the compiler derive omitted type arguments from function arguments and constraint relationships. In Map(ids, strconv.Itoa), ids provides T, and the signature of strconv.Itoa provides U. Once inference succeeds, the caller doesn’t need to write Map[int, string].

Inference works for function calls and other specified contexts; it doesn’t infer a generic type’s arguments from composite-literal fields. You must write the string in Stack[string]{}. A function call can also lack enough information, such as when a type parameter occurs only in the result. In that case, supply a type argument explicitly or redesign the signature so an input carries the relationship.

Untyped constants participate in inference and representability checks. Mixing constant kinds can produce a wider default type than expected, or leave no type that satisfies both the constraint and arguments. API examples and tests should include variables, named types, and untyped constants instead of testing integer literals alone.

Generic types still follow zero-value rules

Generic structs, defined slice types, and other named types follow ordinary Go rules after instantiation. A field’s zero value comes from the actual type argument: var item T is 0 for int and nil for a pointer. When that zero value has domain meaning, returning only T can’t report whether an operation succeeded.

Whether a generic container’s own zero value is usable depends on its fields and methods. A stack backed by a slice can append immediately, so its zero value works well; a set backed by a map must initialize before its first write. A constructor, lazy initialization, or a documented zero-value contract can all work, but the API should choose one clear behavior.

Examples

Preserve input-output relationships with Map

The first example converts invoice numbers to labels. T and U can differ, and the call relies entirely on inference. The return type is still []string, with no assertion required.

Go
package main

import (
	"fmt"
	"strconv"
)

func Map[T, U any](values []T, convert func(T) U) []U {
	result := make([]U, len(values))
	for i, value := range values {
		result[i] = convert(value)
	}
	return result
}

func main() {
	invoiceIDs := []int{7, 21, 42}
	labels := Map(invoiceIDs, func(id int) string {
		return "INV-" + strconv.Itoa(id)
	})

	fmt.Println(labels)
}
[INV-7 INV-21 INV-42]

Map only allocates a result, reads T, calls the converter, and writes U, so any is sufficient. Adding a numeric union wouldn’t enable another operation; it would only reject strings and structs. This signature is useful because it preserves a relationship, not because it loosens runtime types.

Accept named types with a type set

This maximum function needs >, so it declares a type set that actually supports ordering. Score is a distinct named type whose underlying type is int; ~int lets it satisfy the constraint. An extra Boolean distinguishes empty input from a valid zero value.

Go
package main

import "fmt"

type Ordered interface {
	~int | ~int64 | ~float64 | ~string
}

type Score int

func Max[T Ordered](values []T) (T, bool) {
	if len(values) == 0 {
		var zero T
		return zero, false
	}

	best := values[0]
	for _, value := range values[1:] {
		if value > best {
			best = value
		}
	}
	return best, true
}

func main() {
	best, ok := Max([]Score{72, 91, 84})
	fmt.Println(best, ok)

	empty, ok := Max([]Score(nil))
	fmt.Println(empty, ok)
}
91 true
0 false

If ~int becomes int, Score no longer satisfies the constraint. The function doesn’t need to know whether the argument is specifically Score; the constraint has already proved that > is valid for every candidate. The returned best retains the Score type.

Make a generic container’s zero value useful

The underlying representation of Stack[T] is a slice. A nil slice can be appended to, so callers don’t need a constructor. Pop returns (T, bool) to distinguish an empty stack from a stored zero value.

Go
package main

import "fmt"

type Stack[T any] []T

func (s *Stack[T]) Push(value T) {
	*s = append(*s, value)
}

func (s *Stack[T]) Pop() (T, bool) {
	if len(*s) == 0 {
		var zero T
		return zero, false
	}

	last := len(*s) - 1
	value := (*s)[last]
	*s = (*s)[:last]
	return value, true
}

func main() {
	var stages Stack[string]
	stages.Push("review")
	stages.Push("publish")

	for range 3 {
		stage, ok := stages.Pop()
		fmt.Printf("%q %t\n", stage, ok)
	}
}
"publish" true
"review" true
"" false

The receiver is *Stack[T] because both Push and Pop replace the slice descriptor. The element type still comes from the string instantiation. If storage changes to a map, writes through the zero value stop being safe, so the API contract must change too.

Pitfalls

Treating comparable as ordered

Slices, maps, and functions don’t satisfy comparable; most scalars, pointers, channels, interfaces, and arrays or structs with comparable fields do. Comparability still doesn’t imply order. Booleans and structs are immediate counterexamples.

Fix: use the standard library’s cmp.Ordered, or declare a constraint containing only the predeclared and named types you need. When ordering is a domain rule, accept a comparison function instead of assuming < expresses that rule.

Performing extra operations under any

Changing the code to a type switch usually works around the static relationship and misses named types. It also turns every added type branch into runtime maintenance.

Fix: derive the constraint from operations in the body. Embed a method interface when you need a method, declare suitable type terms when you need an operator, and keep any when the implementation only moves values around.

Forgetting ~ and rejecting domain types

Forcing callers to convert a UserID to int discards useful static information and can mix identifiers from different domains. Constraints describe type identity and underlying types, not general conversion rules.

Fix: use ~int if the algorithm is valid for every named type with that underlying type. If the API should accept only the exact predeclared type, retain int and document and test that boundary.

Adding type parameters to a method

Adding every future parameter to the receiver in advance makes each instantiation carry unrelated types. It also promotes a relationship used by one operation into the identity of the whole type.

Fix: write a standalone function such as MapStack[T, U any](Stack[T], func(T) U) Stack[U] when the operation introduces U. Put a parameter on the generic type only when it determines fields or the long-term contract of its methods.

Using a zero value to mean failure

Generated code often introduces var zero T to make a return compile but never decides how the caller should interpret it. The problem isn’t unique to generics, but a type parameter means the zero value’s shape isn’t known while writing the function.

Fix: return (T, bool) or (T, error) and make the caller handle absence explicitly. Return a lone T only when the domain contract makes its zero value the one correct result.

Deep How type sets combine

How type sets combine

Interface elements intersect

A constraint interface starts from the set of all non-interface types, then each element narrows it. A method element keeps types whose method set contains that method, a single type term keeps its corresponding types, and an embedded interface contributes its type set. To satisfy the complete constraint, a type must remain in every set produced by its elements.

This explains why writing both ~int and String() string isn’t an either-or choice. A candidate must have int as its underlying type and declare the required method. The predeclared int type itself has no such method, while a named integer type that declares one can satisfy the intersection.

Unions combine candidate terms

The vertical bar | means union only within one union element. ~int | ~int64 | ~float64 accepts any of those terms, but the constraint’s other elements still apply. Non-interface terms in a multi-term union must be pairwise disjoint, so int | ~int is invalid because the former is already contained in the latter.

A union is a closed list. Choosing one means a future numeric type isn’t accepted automatically, which can be exactly the stable boundary an API needs. If the algorithm depends on behavior rather than predeclared representation, a comparison function or method constraint may be easier to extend.

~ checks underlying types

In an approximation element ~T, T must be its own underlying type and can’t be a type parameter. Given type MyInt int, ~MyInt is invalid because the underlying type of MyInt isn’t itself; write ~int. The compiler then includes every named type with that underlying type.

Sharing an underlying type doesn’t make two named types directly assignable to each other. The constraint decides whether a type can be an argument and which operations the body may use. After instantiation, parameters and results still preserve the named type supplied by the caller.

Inference and instantiation boundaries

Function arguments provide equations

The compiler unifies occurrences of type parameters in function parameters with argument types. If a parameter is []T and its argument is []Invoice, it can derive T as Invoice. If the same parameter occurs in several positions, those positions must provide compatible answers; inference fails instead of picking a common any.

Constraints can provide another relationship. A declaration shaped like [S ~[]E, E any] can first derive S from the argument and then infer E from the underlying slice type of S. Generic slice functions in the standard library often use this shape to preserve a named slice type while still knowing its element type.

Result context isn’t a general inference source

Don’t assume an assignment target can always determine a function’s type arguments backward. The most reliable public APIs let ordinary arguments carry the information required for inference. If a constructor only returns T and receives no value related to T, callers generally need an explicit type argument.

A partial type-argument list can provide leading parameters and let the compiler infer the rest, but that makes parameter order part of usability. Put the parameter callers are most likely to specify first. Testing real calls is more reliable than judging how tidy the declaration looks.

Generic types require explicit instantiation

Using a generic type requires instantiation; field values don’t fill in type arguments. Pair[int, string]{...}, var stack Stack[Task], and ordinary assignment involving an already instantiated value are valid. Writing only Stack{} isn’t contextual shorthand; it omits the type argument.

An instantiated type continues to follow ordinary assignability and method-set rules. Methods with receiver *Stack[T] belong to the corresponding pointer type’s method set; generics don’t change the distinction between value and pointer receivers. When checking interface implementation, inspect Container[T] and *Container[T] separately.

Generic API tradeoffs

Prefer parameters that express relationships

Useful type parameters usually occur more than once in a signature. A parameter may connect input elements to callback arguments, connect a map’s key type to a lookup key, or make container methods return the same element type. A parameter that occurs once with an any constraint often expresses no relationship, and an ordinary parameter or interface may be simpler.

Don’t introduce generics merely to avoid one type assertion. Interfaces suit heterogeneous values and runtime dispatch; type parameters suit consistent types within one instantiation. The choice depends on what information callers must retain, not on which syntax is newer.

Keep constraints minimal and meaningful

A constraint that’s too broad leaves the body unable to express its algorithm, while one that’s too narrow rejects valid callers. The minimal constraint is the one that proves exactly the operations the implementation needs, not the one with the fewest written terms. A [T any] sorter accepting a comparison function may be more general than an enormous Ordered union because the caller supplies the order.

An exported constraint is API. Adding candidate types usually preserves existing calls, but changing available operations, removing a term, or changing method requirements can break users. If a constraint serves one function and has no reuse value, keeping it unexported reduces the promised surface.

Define a clear zero-value contract

A generic function can’t assume the zero value of T is distinguishable from domain absence. Container lookups commonly return (V, bool), while parsing and external operations commonly return (T, error). Those shapes follow map and standard-library conventions and make generated code straightforward to test.

The zero value of the container itself is a separate decision. Slice-backed storage often supports it naturally; maps, channels, and structures requiring a configured capacity usually need an initialization policy. Documentation should distinguish the result contract for a missing element from behavior of an uninitialized container.

Don’t claim performance without measurements

Generic code generation is a compiler implementation detail, not a fixed performance guarantee in the language specification. Different type arguments, method calls, escape behavior, and compiler versions can produce different results, so syntax alone can’t prove zero cost or a degree of code growth.

If performance determines an API choice, benchmark the target Go version with real type arguments and workload, then inspect escape analysis and generated code. Without that evidence, discuss type safety, readability, and maintenance cost instead; those properties are directly reviewable from the source.

Verify generic boundaries

A generic implementation can look correct for one common type and still fail at a constraint boundary. Verification should start from the promised type set, not from the int that current callers happen to use. Compile-time and runtime tests catch different failures, so you need both.

Use compile cases to check constraints

Write a minimal instantiation for every type category the API should support and keep those cases compiling in CI. Include a predeclared type, a named type with the same underlying type, and a type the constraint deliberately rejects. Put the last category in a dedicated compile-failure or analysis-tool test rather than leaving source that breaks the ordinary suite.

Successful compilation proves only that a candidate satisfies the constraint, not that the algorithm returns the right result. Max([]Score{...}) still needs cases for order, duplicates, and empty input. Conversely, runtime tests using only int won’t reveal that a constraint incorrectly excludes Score.

Check inference at call sites

Tests should use the call form the documentation intends to show. If an example omits type arguments, compile that exact call rather than writing the full [int, string] in tests and claiming inference works. Function values, nil arguments, and untyped constants are particularly likely to change an inference result.

Recompile representative calls after reordering parameters or moving a value into the result. The function body may be unchanged while information required for inference has disappeared. API usability lives at the call site, not only in the declaration.

Exercise zero values at runtime

Test every failure path returning T with the valid zero value as successful data. For Stack[string], actually push ""; for Stack[*Task], decide whether a nil pointer is valid domain data. Only then can a test prove that callers handle the extra Boolean or error correctly.

Start container tests from an unconstructed zero value too. A slice implementation may work while a map implementation panics on a write. This test prevents an internal representation change from silently breaking a public zero-value contract.

Tools don’t replace contract review

go test runs behavioral checks and compiles reached instantiations, while go vet checks a selection of suspicious constructs. Neither tool decides whether a constraint is narrower than the business contract or whether a type parameter expresses no relationship. Those questions still require a signature-level review.

A test matrix doesn’t have to enumerate every member of a type set. Pick representatives that distinguish rules: exact and approximate terms, zero and nonzero values, and inferred and explicit calls. Each case should test a way the declaration could fail rather than repeat the same evidence for quantity.

Verify package boundaries

Compile at least one call from a consumer package because exported names, inference, and unexported constraints are fully exercised only across that boundary. In-package tests can miss API problems by seeing extra names.

Keep each call case small and make its failure point to one contract. These are interface compatibility tests, not another algorithm suite.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?