Translated by Claude from the Chinese original.

Generics let us write reusable, type-safe logic over a family of types. In languages such as Java, C++, and C#, generics have long been a core feature. Go intentionally launched without generics, which kept the language simpler but made some abstractions awkward. As Go evolved, the community introduced generics to better support reusable data structures and common algorithms in production code.

An example:

import "golang.org/x/exp/constraints"

func GMin[T constraints.Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

Go generics are similar in spirit to other languages, but different in several important details. This article starts from Go’s type-parameters proposal and uses implementation snippets plus examples to explain key behavior, with the goal of helping you use generics more effectively in real code.

Main reference: https://go.googlesource.com/proposal/+/HEAD/design/43651-type-parameters.md

Terminology

  • parameter (formal parameter): A placeholder declared in a function definition, e.g., a and b in func Add(a, b int) int. In this article, “type parameter” refers to this concept; for example, T in func Add[T any](a, b T) T.
  • argument (actual argument): A concrete value passed at a call site, e.g., a and 2 in sum := Add[int](a, 2). In this article, “type argument” refers to this concept; for example, int in Add[int].
  • function: Refers to a function in Go, such as func Add(a, b int) int. https://go.dev/ref/spec#Function_types
  • method: Refers to a struct method in Go, such as func (s *Struct) Get() string. https://go.dev/ref/spec#Method_declarations
  • operation: Can be understood as operators supported by Go’s built-in types (see below). The Go spec uses the term “operator.” https://go.dev/ref/spec#Operators
Expression = UnaryExpr | Expression binary_op Expression .
UnaryExpr  = PrimaryExpr | unary_op UnaryExpr .

binary_op  = "||" | "&&" | rel_op | add_op | mul_op .
rel_op     = "==" | "!=" | "<" | "<=" | ">" | ">=" .
add_op     = "+" | "-" | "|" | "^" .
mul_op     = "*" | "/" | "%" | "<<" | ">>" | "&" | "&^" .

unary_op   = "+" | "-" | "!" | "^" | "*" | "&" | "<-" .

Overview

The Go type parameters proposal summarizes several key points (https://go.googlesource.com/proposal/+/HEAD/design/43651-type-parameters.md#summary):

  • Functions and types can have type parameters, and those parameters are constrained by interface types. (Methods are not included here; methods cannot declare their own type parameters.)
  • Constraints define which concrete types are allowed as type arguments, and which methods those arguments must provide.
  • Constraints also determine which operations are valid on type parameters inside generic code.
  • At call sites, type inference may infer missing type arguments, so explicit arguments are not always required. For example, with func Add[T any](a, b T) T, we can often write sum := Add(a, 2) instead of sum := Add[int](a, 2).

Next, we’ll explain Go generics design and usage from these perspectives:

  • The specific definition of type constraints
  • How type inference is implemented
  • Some other related topics
  • Selected type inference code

Type Constraints

Go type constraints can be seen as the “type” of type parameters. For example, in the following, T is the type parameter and Stringer is the type constraint—i.e., the type of T.

func Stringify[T Stringer](s []T) (ret []string) {
        for _, v := range s {
                ret = append(ret, v.String())
        }
        return ret
}

The type of a type constraint itself is interface. Before Go 1.18, an interface was a collection of methods. In the example above, Stringer describes that T should be a type with the method func (T) String() string. But if an interface can only be a collection of methods, the following function cannot be implemented:

func Add[T constraints.Integer](a, b T) T {
        return a + b
}

How can constraints.Integer, as an interface, express support for the + operation? The actual definition in the constraints package is:

type Integer interface {
        Signed | Unsigned
}

type Signed interface {
        ~int | ~int8 | ~int16 | ~int32 | ~int64
}

type Unsigned interface {
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

As we can see, the Integer interface doesn’t define any methods. This brings us to the concept of underlying types.

Underlying Type

Reference: https://go.dev/ref/spec#Underlying_types

To make constraints more expressive, generics expanded interfaces from “sets of methods” to “sets of types.” The key difference is built-in operators. In Go, operators such as < and == cannot be overloaded, so user-defined methods alone cannot describe operator support. With underlying types, a named type can reuse operators supported by its underlying type. As a result, interfaces can now express both method sets and type sets (for example, with syntax like ~underlying_type).

The rules for determining a type’s underlying type are:

  1. Types that are their own underlying type:
    1. boolean
    2. numeric
    3. string
    4. type literal
      1. ArrayType
      2. StructType
      3. PointerType
      4. FunctionType
      5. InterfaceType
      6. SliceType
      7. MapType
      8. ChannelType
  2. When none of the above apply, a type’s underlying type is the underlying type of the type it was created from. For example:
type (
    A1 = string
    A2 = A1
)

Here A1 follows rule 1, so its underlying type is string. A2 follows rule 2, and its underlying type is also string.

Looking back at the definition of constraints.Integer, it represents the set of all types whose underlying type is int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, or uintptr. All these types support the + operation, so the Add function can be defined.

Difference Between Using Type Parameters and Using Interfaces Directly

Reference: https://go.googlesource.com/proposal/+/HEAD/design/43651-type-parameters.md#values-of-type-parameters-are-not-boxed

Because constraints are interfaces, Go generics can look similar to interface-based programming. A key difference is that generic functions can preserve and return concrete types, rather than forcing everything through interface{}.

Using the Add example, if we remove the type parameter, it becomes:

func Add(a, b interface{}) interface{} {
        n, _ := a.(int)
        m, _ := b.(int)
        return n + m
}

With this non-generic implementation, the function returns interface{} rather than a caller-specific concrete type. The caller then needs a type assertion to recover the concrete type:

func main() {
        a, b := 1, 2
        c := Add(a, b)
        d, ok := c.(int)
        ...
}

Also recall how Go interfaces are represented: an interface value carries both dynamic type information and the underlying value. That extra boxing/unboxing path may add overhead, which generics can often avoid.

Type Inference

Not every call to a generic function requires explicit type arguments. Under certain conditions, the compiler can infer missing type arguments. Note that inference itself does not complete all semantic checks; some checks happen afterward.

The following sections introduce 3 key concepts of type inference, followed by the complete type inference process.

Type Unification

Reference: https://go.dev/ref/spec#Type_unification

Description

Input

  1. A mapping P -> A, where P is a type parameter and A is a known type argument. For example, for func Add[T any](a, b T) T, a possible mapping is T -> int.
  2. Two types, which may or may not contain type parameters.

Output

  1. Given known mappings, determine whether the two input types can be unified.

Operations

  1. For types without type parameters: the type must be equivalent to the comparison type, otherwise unification fails.
    1. Two identical types are naturally equivalent.
    2. If both types are channel types, they can be considered equivalent if they are identical after ignoring channel direction.
    3. If two types have the same underlying types, they can also be considered equivalent.
  2. For types with type parameters: after abstracting over type parameters, the structure must still align; otherwise unification fails.
    1. For example, []map[T1]T2 and []T3 are structurally consistent—T3 can be substituted with map[T1]T2. Similarly, []map[T1]bool and []map[string]T2 are structurally consistent.
    2. For example, []map[T1]T2 and int, struct{}, []struct{} etc. cannot possibly be structurally consistent.
  3. If matching succeeds and the type contains type parameters, we learn a new P’ -> A’ mapping, which is added to the existing mappings.

Function Argument Type Inference

References:

Description

  1. When calling a function with type parameters, if the caller doesn’t pass type arguments, infer the type arguments from the actual arguments.

Implementation

  1. Get a set of (parameter, argument) pairs from the caller’s actual arguments.
  2. First ignore combinations where the argument has no type (i.e., constants, which have their own type inference rules). For typed (parameter, argument) pairs, perform type unification on their corresponding types and continuously update the mapping P -> A.
  3. Next, handle constant (parameter, argument) pairs. If a parameter’s corresponding type parameter was already inferred in step 2, ignore it. If not, treat the constant argument as its default type and perform type unification.
  4. When all (parameter, argument) pairs have been processed, inference is complete. If any processing fails along the way, inference fails.

Here’s an example illustrating the above steps:

func scale[Number ~int64|~float64|~complex128](v []Number, s Number) []Number {
        ...
}

func main() {
        var vector []float64
        scaledVector := scale(vector, 42)
        ...
}

When function argument type inference begins, we get two (parameter, argument) pairs:

  1. (v []Number, vector []float64)
  2. (s Number, 42)

First, perform type unification on (v []Number, vector []float64), yielding the mapping Number -> float64.

Since we’ve already inferred the mapping Number -> float64, the (s Number, 42) pair doesn’t need type unification.

If there were no mapping Number -> float64, the type of 42 in (s Number, 42) would be treated as the default type int, and the mapping would be Number -> int.

Constraint Type Inference

Reference: https://go.dev/ref/spec#Constraint_type_inference

Description

  1. Based on defined type parameter constraints, infer other unknown type parameters from a known type parameter.
  2. For example, given a function func Double[S ~[]E, E constraints.Integer](s S) S, called as Double([]int{1, 2, 3}), we can infer E -> int from the type constraint S ~[]E and S -> []int.

Implementation

  1. Iterate through all type parameters:
    1. If a type parameter already has a corresponding argument, perform unification on their underlying types. In the Double example, the underlying type of S is []E, so we unify []E and the known argument []int, inferring E -> int.
    2. If a type parameter doesn’t have a corresponding argument, but its type constraint has only one type, infer that the type parameter’s argument is the constraint type.
  2. In known mappings, if we have P -> A and Q -> B and A contains Q, substitute Q in A with B. For example, in func Copy[T any, P *T](value T, dst P), given T -> int and P -> *T, we can infer P -> *int.
  3. Repeat step 2 until no type parameter P can be found that is contained in some type argument A.

Type Inference Execution Steps

Reference: https://github.com/golang/go/blob/go1.18/src/cmd/compile/internal/types2/infer.go#L33

Based on comments in the compiler code, inference proceeds in these steps:

  1. Perform function argument type inference using type arguments.
  2. Perform constraint type inference.
  3. Perform function argument type inference on remaining untyped arguments.
  4. Perform a final round of constraint type inference.

An example:

package main

import "fmt"
import "golang.org/x/exp/constraints"

func Multiple[S ~[]E, E, X constraints.Integer](s S, x X) S {
        for i, e := range s {
                s[i] *= x
        }
        return s
}

type IntVector []int

func main() {
        vector := IntVector{0, 1, 2, 3, 4}
        vector = Multiple(vector, 3)
        fmt.Printf("%s\\n", vector)
        // output: [0, 3, 6, 9, 12]
}

The type inference steps for Multiple:

  1. Perform type inference on typed function arguments, i.e., on (s S, vector IntVector), yielding: S -> IntVector.
  2. Perform constraint type inference. S’s constraint is []E, and IntVector’s underlying type is []int, so unify []E and []int, yielding E -> int.
  3. Perform type inference on untyped function arguments, i.e., on (x X, 3). Take the default value int for constant 3, yielding X -> int.
  4. Perform constraint type inference again, but since all parameter types are known, it terminates early.

Other Topics

A few related notes.

The Generics Dilemma

Reference: https://research.swtch.com/generic

Adding generics to a programming language inevitably increases complexity for at least one of three parties:

  • The programmer: C takes this approach—it only supports generic syntax, but the compiler and runtime don’t address problems introduced by generics. If there are issues, the programmer debugs them.
  • Compilation: C++ takes this approach—types are resolved at compile time and templates are instantiated into concrete code. This increases compilation time and binary size.
  • Runtime: Java takes a different approach centered on erasure, with trade-offs in runtime behavior and type expressiveness.

As discussed above, Go mainly chooses to pay more at compile time: type information needed for generic function instantiation is resolved during compilation.

Using [T] Instead of <T>

Many developers’ first impression of generics comes from the <T> syntax in C++ or Java. The proposal explains that since < and > are also used as comparison operators, distinguishing whether < and > represent comparison or type parameters would create additional burden. So [T] was chosen instead.

Why Don’t Go Generics Support Methods?

This section is somewhat brief. For more detailed explanations and examples, see: https://go.googlesource.com/proposal/+/HEAD/design/43651-type-parameters.md#no-parameterized-methods

In Go, structs can use type parameters, but a struct’s methods are not allowed to have type parameters. The primary reason is Go’s interface characteristics. As mentioned above, interfaces can express “collections of methods”—meaning an interface can represent all structs that implement its defined methods. If methods supported generics, we’d have interface definitions like:

type Phone interface {
        Call[N PhoneNumber](n N)
        Download[A App](a A)
}

Since Go has no explicit implements or extends declaration between structs and interfaces, adding method-level type parameters would require much heavier inference to decide interface conformance, with significant compiler complexity and cost.

Supporting Pointer Methods

When we define a generic function F[T C] and constraint C requires methods, passing a type X will fail if those required methods exist on *X rather than on X.

A concrete example:

type Setter interface {
        Set(string)
}

func FromStrings[T Setter](s []string) []T {
        result := make([]T, len(s))
        for i, v := range s {
                result[i].Set(v)
        }
        return result
}

type Settable int

func (p *Settable) Set(s string) {
        i, _ := strconv.Atoi(s) // real code should not ignore the error
        *p = Settable(i)
}

func F() {
        // INVALID
        nums := FromStrings[Settable]([]string{"1", "2"})
        // Here we want nums to be []Settable{1, 2}.
        ...
}

In the example above, result’s type is []Settable, but Settable doesn’t support the Set method—*Settable does. So result[i].Set(v) can’t be called normally.

The solution:

type Setter2[B any] interface {
        Set(string)
        *B // non-interface type constraint element
}

func FromStrings2[T any, PT Setter2[T]](s []string) []T {
        result := make([]T, len(s))
        for i, v := range s {
                // The type of &result[i] is *T which is in the type set
                // of Setter2, so we can convert it to PT.
                p := PT(&result[i])
                // PT has a Set method.
                p.Set(v)
        }
        return result
}

This pattern explicitly distinguishes value type T from pointer type PT, and encodes their relationship in Setter2[B any]. We then convert &result[i] to PT, so calling Set succeeds.

Using Generics in Practice

With the above knowledge of defining generic functions through type constraints and understanding of type inference, let’s discuss practical applications of generics:

  • Container operations.
  • General-purpose data structures.
  • General-purpose operation logic. In my own code, I call a data-gateway service that returns a fixed structure. I used generics to wrap request/response handling so data from different sources can be converted into target structs with less boilerplate.

If you have other ways of using generics in your work or have recommendations for useful generic libraries, feel free to share in the comments :)

Type Inference Code

Type Unification

Reference: https://github.com/golang/go/blob/go1.18/src/cmd/compile/internal/types2/unify.go

Recursively checks whether x, y Type can unify under mapping p *ifacePair. If x or y is an uninferred type parameter, it can be matched and inference proceeds.

// nify implements the core unification algorithm which is an
// adapted version of Checker.identical. For changes to that
// code the corresponding changes should be made here.
// Must not be called directly from outside the unifier.
func (u *unifier) nify(x, y Type, p *ifacePair) (result bool) {

        ......

        // Cases where at least one of x or y is a type parameter.
        switch i, j := u.x.index(x), u.y.index(y); {
        case i >= 0 && j >= 0:
                // both x and y are type parameters
                if u.join(i, j) {
                        return true
                }
                // both x and y have an inferred type - they must match
                return u.nifyEq(u.x.at(i), u.y.at(j), p)

        case i >= 0:
                // x is a type parameter, y is not
                if tx := u.x.at(i); tx != nil {
                        return u.nifyEq(tx, y, p)
                }
                // otherwise, infer type from y
                u.x.set(i, y)
                return true

        case j >= 0:

                ......

        }

        ......

        switch x := x.(type) {

        ......

        case *Slice:
                // Two slice types are identical if they have identical element types.
                if y, ok := y.(*Slice); ok {
                        return u.nify(x.elem, y.elem, p)
                }

        case *Struct:
                // Two struct types are identical if they have the same sequence of fields,
                // and if corresponding fields have the same names, and identical types,
                // and identical tags. Two embedded fields are considered to have the same
                // name. Lower-case field names from different packages are always different.
                if y, ok := y.(*Struct); ok {
                        if x.NumFields() == y.NumFields() {
                                for i, f := range x.fields {
                                        g := y.fields[i]
                                        if f.embedded != g.embedded ||
                                                x.Tag(i) != y.Tag(i) ||
                                                !f.sameId(g.pkg, g.name) ||
                                                !u.nify(f.typ, g.typ, p) {
                                                return false
                                        }
                                }
                                return true
                        }
                }

        ......

        default:
                panic(sprintf(nil, true, "u.nify(%s, %s), u.x.tparams = %s", x, y, u.x.tparams))
        }

        return false
}

Function Argument Type Inference

Typed Arguments Are Directly Unified

Reference: https://github.com/golang/go/blob/go1.18/src/cmd/compile/internal/types2/infer.go#L250

        // indices of the generic parameters with untyped arguments - save for later
        var indices []int
        for i, arg := range args {
                par := params.At(i)
                // If we permit bidirectional unification, this conditional code needs to be
                // executed even if par.typ is not parameterized since the argument may be a
                // generic function (for which we want to infer its type arguments).
                if isParameterized(tparams, par.typ) {
                        if arg.mode == invalid {
                                // An error was reported earlier. Ignore this targ
                                // and continue, we may still be able to infer all
                                // targs resulting in fewer follow-on errors.
                                continue
                        }
                        if targ := arg.typ; isTyped(targ) {
                                // If we permit bidirectional unification, and targ is
                                // a generic function, we need to initialize u.y with
                                // the respective type parameters of targ.
                                if !u.unify(par.typ, targ) {
                                        errorf("type", par.typ, targ, arg)
                                        return nil
                                }
                        } else if _, ok := par.typ.(*TypeParam); ok {
                                // Since default types are all basic (i.e., non-composite) types, an
                                // untyped argument will never match a composite parameter type; the
                                // only parameter type it can possibly match against is a *TypeParam.
                                // Thus, for untyped arguments we only need to look at parameter types
                                // that are single type parameters.
                                indices = append(indices, i)
                        }
                }
        }

Untyped Arguments Are Assigned Constant Default Values Then Unified

Reference: https://github.com/golang/go/blob/go1.18/src/cmd/compile/internal/types2/infer.go#L297

        // Use any untyped arguments to infer additional type arguments.
        // Some generic parameters with untyped arguments may have been given
        // a type by now, we can ignore them.
        for _, i := range indices {
                tpar := params.At(i).typ.(*TypeParam) // is type parameter by construction of indices
                // Only consider untyped arguments for which the corresponding type
                // parameter doesn't have an inferred type yet.
                if targs[tpar.index] == nil {
                        arg := args[i]
                        targ := Default(arg.typ)
                        // The default type for an untyped nil is untyped nil. We must not
                        // infer an untyped nil type as type parameter type. Ignore untyped
                        // nil by making sure all default argument types are typed.
                        if isTyped(targ) && !u.unify(tpar, targ) {
                                errorf("default type", tpar, targ, arg)
                                return nil
                        }
                }
        }

Constraint Type Inference

Reference: https://github.com/golang/go/blob/go1.18/src/cmd/compile/internal/types2/infer.go#L468

Core Type Processing for Type Parameters

In the first phase of constraint type inference, a new concept called “core type” is introduced. We won’t go into too much detail here—it can be understood as the underlying type of the type corresponding to a type constraint. Using core types and known arguments, some type inferences can be completed.

                for i, tpar := range tparams {
                        // If there is a core term (i.e., a core type with tilde information)
                        // unify the type parameter with the core type.
                        if core, single := coreTerm(tpar); core != nil {
                                // A type parameter can be unified with its core type in two cases.
                                tx := u.x.at(i)
                                switch {
                                case tx != nil:

                                        ......

                                        if !u.unify(tx, core.typ) {
                                                // TODO(gri) improve error message by providing the type arguments
                                                //           which we know already
                                                // Don't use term.String() as it always qualifies types, even if they
                                                // are in the current package.
                                                tilde := ""
                                                if core.tilde {
                                                        tilde = "~"
                                                }
                                                check.errorf(pos, "%s does not match %s%s", tpar, tilde, core.typ)
                                                return nil, 0
                                        }

                                case single && !core.tilde:
                                        // The corresponding type argument tx is unknown and there's a single
                                        // specific type and no tilde.
                                        // In this case the type argument must be that single type; set it.
                                        u.x.set(i, core.typ)

                                default:
                                        // Unification is not possible and no progress was made.
                                        continue
                                }

                                ......

                        }
                }

Mapping Simplification

The second phase of constraint type inference continuously simplifies the mappings.

                smap := makeSubstMap(tparams, types)
                n := 0
                for _, index := range dirty {
                        t0 := types[index]
                        if t1 := check.subst(nopos, t0, smap, nil); t1 != t0 {
                                types[index] = t1
                                dirty[n] = index
                                n++
                        }
                }