The copy-paste trap
You write a function to find the maximum value in a slice. It works perfectly for int. Then a colleague asks for the same logic for float64. You copy the function, rename it MaxFloat, and swap the types. A week later, you need Max for int64. You copy again. Now you maintain three functions with identical logic that differ only by type.
Go generics let you write one function that handles all types. You replace int with a type parameter T. But if you write func Max[T](items []T) T, the compiler allows you to call Max([]string{"a", "b"}). Strings don't support the > operator. The compiler doesn't know what T can do. It assumes T is a blank slate. You need to tell the compiler which types are allowed. You do that with a type constraint.
A type constraint is a rule attached to a type parameter. It limits the types that can be passed to the function. The compiler checks every call site against the constraint. If the type matches, the code compiles. If not, the compiler rejects the program before it runs. Constraints turn a generic function from a wildcard into a precise tool.
What a type constraint actually is
A type constraint is an interface definition that describes a set of allowed types. You define the constraint, then attach it to the type parameter using the syntax [T Constraint]. The constraint can require specific methods, specific underlying types, or a combination of both.
Think of a constraint like a key shape. The function is a lock. The constraint defines the grooves in the key. Only keys with the right grooves turn the lock. If you try to use a key with the wrong shape, it doesn't fit. The compiler acts as the key cutter. It checks the shape of the type you pass against the constraint. If the shape doesn't match, it stops you immediately.
Constraints live in the type system. They don't add runtime overhead. The compiler uses the constraint to verify correctness and to generate specialized versions of the function. When you call a generic function, the compiler creates a copy of the function tailored to the specific type. The constraint ensures that the generated code is valid for that type.
Defining a numeric constraint
The most common use case is constraining a type to numeric values. Go has many integer types: int, int8, int16, int32, int64, and their unsigned counterparts. You often want a function to work with all of them. You can define a constraint that lists every allowed underlying type.
Here's the standard way to define a numeric constraint. You create an interface that lists allowed underlying types, then attach it to a type parameter.
// Integer matches any type whose underlying type is an integer variant.
// The ~ prefix allows named types based on int, not just int itself.
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}
// Sum adds all values of type T.
// T must satisfy the Integer constraint.
func Sum[T Integer](values ...T) T {
var total T
for _, v := range values {
total += v
}
return total
}
The ~ prefix is the wildcard for underlying types. Go distinguishes between a type and its underlying type. When you write type Celsius int, you create a new type Celsius whose underlying type is int. They are not interchangeable. You cannot pass a Celsius to a function expecting int without a conversion. The tilde operator bridges this gap. ~int matches any type whose underlying type is int, including Celsius. Without the tilde, the constraint only matches the exact type int.
The | operator creates a union. ~int | ~int8 means the type can be either an underlying int or an underlying int8. The constraint accepts any type that matches at least one option in the union.
The compiler checks the constraint at every call site. If you call Sum(1, 2, 3), the compiler infers T as int. It checks int against Integer. int matches ~int. Success. If you call Sum("a", "b"), the compiler checks string against Integer. string doesn't match any option. The compiler rejects the program with cannot use "a" (untyped string constant) as Integer value in argument.
The tilde is the wildcard for underlying types. Use it to embrace named types.
How the compiler uses your constraint
When you define a constraint, the compiler builds a type set. The type set is the collection of all types that satisfy the constraint. For Integer, the type set includes int, int8, Celsius, MyInt, and every other type whose underlying type is an integer variant.
At compile time, the compiler performs two checks. First, it verifies that the type argument belongs to the type set. If you pass a type outside the set, the compiler emits an error. Second, it verifies that the function body only uses operations valid for all types in the set. If you try to use + on a constraint that includes string, the compiler rejects the function definition with operator + not defined on T.
This second check is powerful. It catches bugs in the generic function itself. You can't write a function that claims to work with integers but accidentally uses a string operation. The constraint protects the function body as much as it protects the call site.
Go follows the convention "accept interfaces, return structs." Constraints are interfaces. Your function accepts the constraint interface. The caller passes a struct or type that implements the constraint. This keeps the generic function decoupled from concrete types. The function doesn't need to know about Celsius or MyInt. It only knows that the type supports addition.
Constraints are promises. The compiler enforces them so your runtime doesn't crash.
Constraining on behavior
Numeric constraints rely on underlying types. Many constraints rely on behavior. You can define a constraint that requires specific methods. This is useful when the operation depends on what the type can do, not what the type is.
Here's a constraint based on behavior rather than underlying type. You define an interface with the methods you need, then use it as the constraint.
// Printable requires any type to have a Print method.
// This is a method-based constraint, not a type-based one.
type Printable interface {
Print()
}
// BatchPrint calls Print on every item in the slice.
// The compiler ensures every element has a Print method.
func BatchPrint[T Printable](items []T) {
for _, item := range items {
item.Print()
}
}
The constraint Printable defines an interface with a single method Print(). Any type that implements Print() satisfies the constraint. The compiler checks for the method signature. If you pass a slice of types that don't have Print(), the compiler rejects the call with T does not implement Printable (missing method Print).
You can combine method constraints with type unions. This is common when you want to support both custom types and built-in primitives. For example, a logging function might accept types that implement Stringer, or it might accept int and string directly.
import "fmt"
// Loggable combines a method requirement with primitive support.
// Types must either implement String() or be an underlying int/string.
type Loggable interface {
fmt.Stringer | ~int | ~string
}
// LogValue prints the value using the most appropriate method.
// It demonstrates a constraint that mixes interfaces and types.
func LogValue[T Loggable](v T) {
// Use a type assertion to handle the Stringer case.
// This is safe because the constraint guarantees v is either Stringer or a primitive.
if s, ok := any(v).(fmt.Stringer); ok {
fmt.Println(s.String())
return
}
// Fall back to default formatting for primitives.
// The constraint ensures v is int or string here.
fmt.Println(v)
}
The constraint Loggable accepts any type that implements fmt.Stringer, plus any type with an underlying int or string. The function body uses a type assertion to distinguish between the cases. The constraint guarantees that the assertion covers all possibilities. If v is not a Stringer, it must be an int or string because of the constraint.
Behavior beats structure. Constrain on methods when the operation depends on what the type can do.
Pitfalls and compiler errors
Type constraints have a few common traps. The first is forgetting the tilde when you need it. If you define type Integer interface { int }, the constraint only matches the exact type int. It does not match int64, uint, or type MyInt int. If you pass MyInt, the compiler rejects the program with cannot use MyInt as Integer in argument. Adding ~int fixes the issue by matching the underlying type.
The second trap is over-constraining. If you define type Integer interface { ~int }, the constraint only matches types with an underlying int. It excludes int64 and uint. Your function becomes less flexible than it needs to be. List all the underlying types you want to support. Use the union operator to combine them.
The third trap is under-constraining. If you use any as the constraint, the function accepts every type. But if the function body uses +, the compiler rejects the definition with operator + not defined on T. The constraint must be tight enough to support the operations in the body. If you need addition, constrain to numeric types. If you need comparison, constrain to comparable or constraints.Ordered.
The fourth trap is assuming constraints are checked at runtime. Constraints are compile-time only. They don't generate runtime checks. If the compiler accepts the code, the constraint is satisfied. You don't need to add if statements to verify types inside the function. The compiler has already done the work.
The compiler error is a gift. It catches the mismatch before the user does.
When to use which constraint
Go gives you several ways to constrain types. Pick the right one based on what your function needs.
Use a union of underlying types with ~ when you need to support a family of built-in types like all integers or all floats. This pattern is common for arithmetic functions, sorting utilities, and numeric helpers.
Use a method-based interface constraint when the operation depends on behavior, such as calling String(), Marshal(), or Close(). This pattern is common for serialization, logging, and resource management.
Use comparable when you need to use == or != or pass the type as a map key. The comparable constraint includes all types that support equality comparison, excluding slices, maps, and functions.
Use any when the function treats the value as an opaque blob and only stores or passes it along. This pattern is common for caches, queues, and generic containers that don't inspect the value.
Use a specific type like int when you only expect that exact type and want to avoid the overhead of generic instantiation. Generics add a small compile-time cost. If the function is never reused for other types, a concrete type is simpler and faster to compile.
Pick the tightest constraint that still allows your code to work. Broad constraints invite bugs.