A type parameter lets one function or type work across many concrete types while the compiler still checks every operation. This tutorial shows the exact [T Constraint] syntax, what each constraint permits you to do with a value, how inference decides T for you, and the two errors that trip up everyone: the method limitation and the missing ~. Every example here was compiled and run on Go 1.24.7. Works with Go 1.21+.
If you want the wider picture first, the complete generics guide covers the design and history. This page is the mechanical reference: syntax, constraints, inference, instantiation.

What a type parameter actually is
A type parameter is a placeholder for a type, declared in square brackets before the ordinary parameter list. The caller (or the compiler, via inference) fills it in with a concrete type, and Go compiles a version that works for that type.
Here is the smallest useful example. first returns the first element of a slice of anything:
package main
import "fmt"
// first returns the first element of any slice. T can be anything,
// so inside the function all you can do is move a T around.
func first[T any](items []T) (T, bool) {
var zero T
if len(items) == 0 {
return zero, false
}
return items[0], true
}
func main() {
names := []string{"ada", "alan", "grace"}
name, ok := first(names)
fmt.Println(name, ok)
ids := []int{101, 102}
id, ok := first(ids)
fmt.Println(id, ok)
}
ada true
101 true
Read the signature left to right. [T any] declares one type parameter named T with the constraint any. items []T is a normal parameter whose element type is T. The return (T, bool) reuses the same T. Inside the body, var zero T is the zero value of whatever T turns out to be: "" for strings, 0 for ints, nil for pointers.
The word after the name, any, is the constraint. It is the most important part of the whole feature, and it is where most people go wrong.

The constraint is the set of permitted types, and it controls what you can do
A constraint is an interface. It defines two things at once: which types are allowed as the argument, and which operations you are allowed to perform on a value of the type parameter inside the function body. Those two are the same rule viewed from two sides. You can only do an operation if every type the constraint permits supports it.
any permits every type. That is maximum flexibility for the caller and minimum power for you. With any, the only things you can do to a T value are store it, copy it, pass it, and put it in a slice or map. You cannot compare it, add it, or print its fields, because not every type supports those.
Try to add two any values and the compiler stops you:
func total[T any](nums []T) T {
var sum T
for _, n := range nums {
sum += n // T is any: + is not defined
}
return sum
}
./main.go:6:3: invalid operation: operator + not defined on sum
(variable of type T constrained by any)
The fix is to narrow the constraint so + is guaranteed. Three constraints cover the common cases.
comparable unlocks == and !=
comparable is a predeclared constraint that permits any type usable with == and != (and therefore any type usable as a map key). With it, you can compare T values:
package main
import "fmt"
// indexOf reports the position of target, or -1. comparable lets you use ==.
func indexOf[T comparable](items []T, target T) int {
for i, item := range items {
if item == target {
return i
}
}
return -1
}
func main() {
fmt.Println(indexOf([]string{"get", "post", "put"}, "post"))
fmt.Println(indexOf([]int{8080, 8443, 9090}, 5000))
}
1
-1
Swap comparable for any here and item == target fails to compile with the same shape of error as above. The constraint is what makes the == legal.
A numeric constraint unlocks +
To use +, every permitted type must support +. There is no predeclared constraint for that, so you write one as an interface listing the types (a type set):
package main
import "fmt"
// Number is the set of types the + below is allowed on.
type Number interface {
~int | ~int64 | ~float64
}
func sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(sum([]int{10, 20, 30}))
fmt.Println(sum([]float64{1.5, 2.5, 4.0}))
}
60
8
~int | ~int64 | ~float64 is a union of three terms. The ~ in front of each matters and gets its own section below. Because every type in that set supports +, the compiler accepts total += n. The standard library ships cmp.Ordered (Go 1.21) for the sortable case, so you rarely hand-roll a numeric constraint in practice. The constraints and comparable guide goes deep on the type-set rules and the constraint packages.
The pattern to internalize: pick the loosest constraint that still permits the operations you need. any if you only pass values around, comparable if you compare, a type set if you do arithmetic.
Multiple type parameters and their relationships
A function or type can declare several type parameters, and they can constrain each other’s roles. A map helper is the canonical example. Map keys must be comparable; values can be anything:
package main
import (
"fmt"
"sort"
)
// keys returns the keys of any map. K must be comparable (all map keys are),
// V can be anything because the values are only copied out.
func keys[K comparable, V any](m map[K]V) []K {
out := make([]K, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func main() {
prices := map[string]float64{"usd": 1.0, "eur": 1.09, "gbp": 1.27}
ks := keys(prices)
sort.Strings(ks) // map order is random; sort for a stable print
fmt.Println(ks)
}
[eur gbp usd]
[K comparable, V any] reads as: two type parameters, the first must be usable as a map key, the second is unrestricted. The parameter m map[K]V ties them together into one map type. When you call keys(prices), the compiler sees map[string]float64 and binds K=string, V=float64 in one shot. Multiple type parameters that share letters ([T, U any]) collapse to one constraint applied to both, exactly like grouping function parameters of the same type.
Type inference in depth: when you can drop the brackets
Most of the time you never write the type arguments. The compiler infers them. Understanding how tells you the one case where you must be explicit.
Go runs two kinds of inference. Function-argument inference matches the types of the values you pass against the parameter types that mention type parameters. Constraint inference then fills in any remaining type parameters using the constraints. The full algorithm is documented in the type inference blog post; the practical rule is simpler.
Here both type parameters are inferred from arguments:
package main
import "fmt"
// transform maps a []T to a []U using fn.
func transform[T, U any](items []T, fn func(T) U) []U {
out := make([]U, len(items))
for i, item := range items {
out[i] = fn(item)
}
return out
}
func main() {
nums := []int{1, 2, 3}
// Both T and U inferred: T=int from nums, U=string from fn's return.
labels := transform(nums, func(n int) string {
return fmt.Sprintf("#%d", n)
})
fmt.Println(labels)
}
[#1 #2 #3]
T comes from nums being []int. U comes from the function literal returning string. Every type parameter appears in an argument, so inference succeeds and the call site stays clean.
The exact case where inference fails
Inference can only work from arguments. If a type parameter appears only in the return type and nowhere in the parameters, there is nothing to infer from:
// newBuffer returns a slice of T. T appears only in the return type,
// so there is no argument for the compiler to infer it from.
func newBuffer[T any](size int) []T {
return make([]T, 0, size)
}
func main() {
buf := newBuffer(16) // nothing here says what T is
fmt.Println(len(buf))
}
./main.go:12:18: in call to newBuffer, cannot infer T
(declared at ./main.go:7:16)
size int tells the compiler nothing about T. The fix is to name it at the call site:
func newBuffer[T any](size int) []T {
return make([]T, 0, size)
}
func main() {
buf := newBuffer[byte](16) // name T explicitly
fmt.Println(len(buf), cap(buf))
}
0 16
newBuffer[byte](16) is instantiation: naming a concrete version of a generic function. Any time you write a constructor-style generic (a function whose whole job is to produce a T or a container of T), expect to instantiate it explicitly, because the return type is the only place T shows up.

Instantiation: naming a concrete version
Instantiation is writing the type arguments in brackets to get a specific, non-generic function or type out of a generic one. transform[int, string] is a concrete function of type func([]int, func(int) string) []string. You can call it directly or bind it to a variable:
func main() {
nums := []int{1, 2, 3}
// Explicit instantiation: name the concrete version. Legal but redundant
// here, since both types are inferable from the arguments.
labels := transform[int, string](nums, func(n int) string {
return fmt.Sprintf("v%d", n)
})
fmt.Println(labels)
// You can also bind a partially/fully instantiated function to a variable.
intToStr := transform[int, string]
fmt.Println(intToStr([]int{9}, func(n int) string { return fmt.Sprint(n) }))
}
[v1 v2 v3]
[9]
For types, instantiation is how you name a field or variable: Stack[string], map[string][]order. For functions, prefer inference and instantiate only when inference cannot reach the answer. Writing transform[int, string](...) when the arguments already pin both types is just noise the reader has to parse.
Type parameters on types, and the method limitation
Types can be generic too. The type parameter goes on the type declaration, and methods repeat it in the receiver:
package main
import "fmt"
// Stack is a generic type: the type parameter goes on the type itself.
type Stack[T any] struct {
items []T
}
// Methods repeat the type parameter in the receiver, but cannot add new ones.
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, true
}
func main() {
var s Stack[string] // instantiate with a concrete type
s.Push("first")
s.Push("second")
top, _ := s.Pop()
fmt.Println(top)
}
second
Stack[string] is a concrete type; you use it exactly like []string or map[string]int. The receiver (s *Stack[T]) reuses the type’s T. The generic data structures guide builds this out into real containers.
Here is the limitation everyone hits. A method cannot introduce a type parameter of its own. Suppose you want a Map method on Stack that transforms to a different type U:
// Trying to give a method its own type parameter U.
func (s *Stack[T]) Map[U any](fn func(T) U) []U {
out := make([]U, len(s.items))
for i, item := range s.items {
out[i] = fn(item)
}
return out
}
./main.go:8:23: syntax error: method must have no type parameters
This is a hard syntax rule, not a temporary gap. A method may use the type parameters of its receiver, but it cannot declare new ones. The reason is method sets and interface satisfaction: a type with a parameterized method would have an unbounded method set, which breaks how interfaces are checked.
The workaround is a package-level generic function, which can introduce as many type parameters as it wants:

package main
import "fmt"
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) }
// mapStack is a package-level generic function. It can introduce U freely.
func mapStack[T, U any](s *Stack[T], fn func(T) U) []U {
out := make([]U, len(s.items))
for i, item := range s.items {
out[i] = fn(item)
}
return out
}
func main() {
var s Stack[int]
s.Push(2)
s.Push(4)
lengths := mapStack(&s, func(n int) string {
return fmt.Sprintf("%d bytes", n)
})
fmt.Println(lengths)
}
[2 bytes 4 bytes]
This is exactly why the standard slices and maps packages are functions like slices.Map-style helpers rather than methods. When you need a second type parameter, it becomes a free function that takes the receiver as its first argument.
The ~ token: why named types get rejected without it
The ~ in a constraint means “any type whose underlying type is this.” Without it, the constraint matches only that exact type. This is the single most confusing part of constraints, and it bites the moment you define a named type.
type Celsius float64 has underlying type float64 but is not identical to float64. A constraint that lists float64 without ~ rejects Celsius:
package main
import "fmt"
// Celsius is a named type whose underlying type is float64.
type Celsius float64
// Without ~: the constraint admits float64 exactly, not types based on it.
type StrictFloat interface {
float64
}
func addStrict[T StrictFloat](a, b T) T { return a + b }
func main() {
fmt.Println(addStrict(Celsius(20), Celsius(5)))
}
./main.go:16:23: Celsius does not satisfy StrictFloat
(possibly missing ~ for float64 in StrictFloat)
The compiler even names the fix. Add ~:
package main
import "fmt"
type Celsius float64
// With ~: admits any type whose underlying type is float64, including Celsius.
type Float interface {
~float64
}
func addFloat[T Float](a, b T) T { return a + b }
func main() {
fmt.Println(addFloat(Celsius(20), Celsius(5)))
fmt.Println(addFloat(1.5, 2.5)) // plain float64 still works
}
25
4
Rule of thumb: if your constraint lists concrete types, use ~ on each one unless you have a specific reason to reject named types. Domain code is full of named types (type UserID int, type Cents int64), and a constraint without ~ silently locks them all out. This is why ~int | ~int64 | ~float64 in the Number example earlier carried the tildes.
Layer 3: a small generic helper package
Here is how the pieces fit together in a package you might actually keep. slicekit provides two helpers over slices, one exercising multi-parameter inference and one exercising a ~ constraint.
// Package slicekit is a tiny generic helper library over slices and maps.
package slicekit
// Ordered is any type whose underlying type supports <. The ~ tokens let
// named types (type Priority int) satisfy it, not just the builtins.
type Ordered interface {
~int | ~int64 | ~float64 | ~string
}
// GroupBy indexes items by a key derived from each item.
// K comparable is required because it becomes a map key; V any because
// values are only stored. Both are inferred from the arguments at the call.
func GroupBy[K comparable, V any](items []V, keyFn func(V) K) map[K][]V {
out := make(map[K][]V)
for _, item := range items {
k := keyFn(item)
out[k] = append(out[k], item)
}
return out
}
// Max returns the largest item by a derived key. The key is Ordered so
// the > comparison is legal inside the function.
func Max[V any, K Ordered](items []V, keyFn func(V) K) (V, bool) {
var best V
if len(items) == 0 {
return best, false
}
best = items[0]
bestKey := keyFn(best)
for _, item := range items[1:] {
if k := keyFn(item); k > bestKey {
best, bestKey = item, k
}
}
return best, true
}
The caller never writes a single type argument, because every type parameter appears in the arguments:
package main
import (
"fmt"
"sort"
"example.com/l3/slicekit"
)
type order struct {
id string
region string
total float64
}
// Priority is a named type; its underlying type is int, so the ~int in
// slicekit.Ordered lets it satisfy the constraint.
type Priority int
func main() {
orders := []order{
{"A-1", "eu", 49.99},
{"A-2", "us", 240.00},
{"A-3", "eu", 12.50},
{"A-4", "us", 80.00},
}
// K=string, V=order, both inferred. No explicit instantiation.
byRegion := slicekit.GroupBy(orders, func(o order) string { return o.region })
regions := make([]string, 0, len(byRegion))
for r := range byRegion {
regions = append(regions, r)
}
sort.Strings(regions)
for _, r := range regions {
fmt.Printf("%s: %d orders\n", r, len(byRegion[r]))
}
// V=order, K=float64, inferred from the keyFn return.
biggest, ok := slicekit.Max(orders, func(o order) float64 { return o.total })
fmt.Println("biggest:", biggest.id, ok)
// Named type through a ~ constraint: K=Priority (underlying int).
tasks := []Priority{2, 9, 4}
top, _ := slicekit.Max(tasks, func(p Priority) Priority { return p })
fmt.Println("top priority:", top)
}
eu: 2 orders
us: 2 orders
biggest: A-2 true
top priority: 9
That last call is the payoff of ~. Priority is a named type over int; because Ordered uses ~int, Max accepts a []Priority with zero fuss. Drop the tildes from Ordered and only the order/float64 calls compile while the Priority call fails. For the deeper patterns behind functions like these, see writing generic functions.
Common mistakes
Expecting method type parameters. You cannot add a new type parameter on a method (func (s *Stack[T]) Map[U any]... is a syntax error). Reach for a package-level function that takes the receiver as an argument. This is a permanent language rule, not a missing feature.
Forgetting ~ so named types are rejected. A constraint listing float64 accepts only float64, not type Celsius float64. The error reads Celsius does not satisfy ..., and the compiler usually suggests the missing ~. Default to ~ on every concrete type in a constraint.
Over-constraining. Requiring comparable when you never compare, or a numeric constraint when you only store values, shrinks the set of callers for no benefit. Pick the loosest constraint that still permits the operations in the body. If you only pass a value around, any is correct.
Redundant explicit instantiation. Writing transform[int, string](nums, fn) when both types are inferable from the arguments adds noise. Instantiate explicitly only when inference genuinely fails, which is almost always a type parameter that appears only in the return type. Let the compiler do the rest.
One more judgment call worth stating plainly: type parameters are for code where the logic is identical across types (containers, slice and map helpers). If an interface already expresses the abstraction, use the interface. Generics constrain concrete types; interfaces abstract behavior. They solve different problems.
What next
You now have the full mechanical model: the [T Constraint] syntax, what each constraint permits, how inference decides T, when to instantiate, the method limitation, and the ~ token. Keep going with these:
- Go generics: the complete guide for the design rationale, performance model, and where generics fit against interfaces.
- The constraints and comparable guide for type sets, the
cmpandconstraintspackages, and the deepercomparablerules. - Writing generic functions and generic data structures for the real-world patterns built on this syntax.
- The Go cheatsheet for a one-page reference, and the main Go tutorial if you want to fill in surrounding language features.
