The duplication trap
You write a function to find the maximum value in a slice. You start with int. It works. Then you need the same logic for float64. You copy the function, rename it MaxFloat, and change the type. Then a string comparison comes along. Now you have three functions with identical bodies. The duplication is annoying. Worse, if you fix a bug in MaxInt, you have to remember to fix it in the other two. You want a single function that accepts any type.
Go generics let you do this. Type constraints let you restrict that function to types that actually make sense. You don't want Max accepting channels or functions. You want numbers or strings. Constraints give you that precision. They turn a generic type parameter from a wildcard into a typed contract.
Constraints are compile-time contracts
A type constraint is a rule attached to a generic type parameter. It defines the set of types allowed to substitute for the generic variable. You write the constraint using an interface. This interface can list methods, or it can list types. The compiler enforces the constraint. If you call the generic function with a type outside the constraint, the build fails.
This happens at compile time. There is no runtime penalty. The constraint disappears from the generated code. You get the flexibility of dynamic typing with the safety of static analysis. The compiler knows exactly what operations are valid because the constraint guarantees them.
Think of a constraint like a bouncer at a club. The bouncer checks your ID. If you meet the requirements, you get in. If not, you're turned away before you even step inside. In Go, the bouncer is the compiler, and the ID is the type you pass. The check happens when you write the code, not when the program runs.
Minimal example: equality with comparable
Here's a generic Contains function. It works for any slice of comparable values. The comparable constraint is a predeclared identifier in Go. It restricts the type parameter to types that support == and !=.
// Contains checks if a slice holds a specific value.
// T is constrained to comparable types so the == operator is valid.
func Contains[T comparable](slice []T, target T) bool {
// Iterate over the slice to find the target.
for _, item := range slice {
// The compiler allows == here because T is comparable.
if item == target {
return true
}
}
// Target not found.
return false
}
func main() {
// Type inference picks T as int.
hasOne := Contains([]int{1, 2, 3}, 2)
// Type inference picks T as string.
hasFoo := Contains([]string{"bar", "baz"}, "foo")
}
The comparable constraint is strict. It includes basic types like int, string, and bool. It also includes structs and pointers. It excludes slices, maps, and functions because those types cannot be compared with ==. If you try to pass a slice of slices, the compiler rejects it.
Constraints are compile-time guards. Don't use interface{} when you need operations.
What happens at compile time
When you call Contains([]int{1, 2}, 1), the compiler looks at the arguments. It infers T is int. It checks int against comparable. int is comparable. The compiler generates a specialized version of Contains for int.
The generated code looks like a hand-written ContainsInt. There is no interface boxing. There is no reflection. The constraint is erased. The binary contains direct comparisons. This is why generics in Go have zero runtime overhead compared to concrete types. The constraint exists only to guide the compiler.
Type inference usually works automatically. You rarely need to write Contains[int]. The compiler figures out T from the arguments. If the arguments are ambiguous, you can specify the type explicitly.
Realistic example: method constraints
Type constraints aren't limited to built-in types. You can define custom interfaces that require methods. This is useful when the operation depends on behavior rather than the underlying data structure.
Here's a function that executes a batch of tasks. It accepts any slice of types that implement a Run method. The constraint ensures every element can be executed.
// Runner defines the behavior required by the executor.
// Any type with a Run method satisfies this constraint.
type Runner interface {
Run() error
}
// Execute runs all runners and returns the first error encountered.
// T is constrained to Runner so r.Run() is valid inside the loop.
func Execute[T Runner](runners []T) error {
for _, r := range runners {
// Call the method guaranteed by the constraint.
if err := r.Run(); err != nil {
return err
}
}
return nil
}
// BackupJob implements Runner.
type BackupJob struct {
Path string
}
// Run performs the backup logic.
func (j BackupJob) Run() error {
// Simulate work.
return nil
}
func main() {
// Execute accepts []BackupJob because BackupJob implements Runner.
err := Execute([]BackupJob{
{Path: "/data"},
{Path: "/config"},
})
if err != nil {
// Handle error.
}
}
The constraint T Runner means T can be any type that has a Run() error method. It doesn't matter if T is a struct, a pointer, or a custom type. As long as the method exists, the compiler accepts it. This pattern replaces the need for interface slices and type assertions in many cases.
Method constraints enforce behavior. Type sets enforce structure.
Type sets and the tilde operator
Sometimes you want to restrict a type parameter to specific underlying types, not just methods. You can list types directly in the interface. The tilde operator ~ is essential here. It includes the underlying type and all named types based on it.
Without the tilde, the constraint matches only the exact type. With the tilde, it matches the family of types.
// Numeric restricts T to int or any type based on int.
// The ~ operator includes type ID int alongside plain int.
type Numeric interface {
~int | ~float64
}
// Sum adds two numeric values.
// T is constrained to Numeric so arithmetic is valid.
func Sum[T Numeric](a, b T) T {
return a + b
}
// ID is a custom type based on int.
type ID int
func main() {
// Works with int.
total := Sum(10, 20)
// Works with ID because ID has underlying type int.
idSum := Sum(ID(1), ID(2))
}
If you wrote interface{ int | float64 } without the tilde, ID would be rejected. The compiler would complain that ID is not in the type set. The tilde unlocks custom types based on primitives. It makes constraints practical for real codebases where domain types are common.
The tilde includes underlying types. Use it whenever you want custom types to work.
Combining methods and types
You can combine method requirements and type sets in a single constraint. This is powerful for complex scenarios. The constraint requires the type to satisfy all parts of the interface.
// Stringer is a standard interface from fmt.
type Stringer interface {
String() string
}
// PrintableNumber requires the type to be numeric AND printable.
// Both conditions must be met.
type PrintableNumber interface {
Numeric
Stringer
}
// FormatNumber prints a number using its String method.
func FormatNumber[T PrintableNumber](v T) string {
// v.String() is valid because of Stringer.
// v can be used in arithmetic because of Numeric.
return v.String()
}
type ID int
// String implements Stringer for ID.
func (id ID) String() string {
return fmt.Sprintf("ID(%d)", id)
}
func main() {
// ID satisfies PrintableNumber because it is ~int and has String().
result := FormatNumber(ID(42))
}
The PrintableNumber constraint requires the type to be in the Numeric set and implement Stringer. ID works because it has underlying type int and defines String(). Plain int does not work because it lacks String(). This combination lets you express precise requirements.
Combine constraints to narrow the type set. Be careful not to make the set empty.
Pitfalls and compiler errors
Generics introduce new failure modes. The compiler catches most mistakes, but the error messages can be confusing if you don't know what to look for.
If you try to use == on a type parameter without comparable, the compiler rejects it. You get invalid operation: a == b (operator == not defined on T). This happens because the compiler doesn't know if T supports equality. You must add the constraint.
Slices and maps are not comparable. If you constrain T comparable, you cannot pass []int or map[string]int. The compiler says invalid operation: cannot compare slice with ==. This is a common trap. You cannot use comparable for slices. You need a different approach, like iterating and comparing elements manually.
If you define a constraint with methods but pass a type that lacks them, the compiler complains. You might see cannot use MyType (type MyType) as type T in argument: MyType does not implement T (missing Run method). Check the method signature. Receiver types matter. If the constraint requires a value receiver, a pointer type might not satisfy it unless the method is also defined on the pointer.
Type inference can fail if the compiler cannot determine T. You get type-checking is only supported when the type is fully specified. This happens with empty slices or nil arguments. Provide explicit type arguments or ensure the arguments carry enough type information.
Generics add complexity. Don't use them when a concrete type suffices.
Decision matrix
Use a type constraint when you need a generic function to operate on multiple types while preserving compile-time safety and avoiding type assertions.
Use a method-based constraint when the operation depends on behavior, such as calling Write or Run, rather than the underlying data structure.
Use a type-set constraint with the tilde operator when you want to include custom types based on an underlying primitive, like allowing type ID int alongside int.
Use the comparable predeclared constraint when you need equality checks or map keys, as slices and maps are not comparable by default.
Use any (the alias for interface{}) when the type is truly opaque and you only store or pass it through without inspecting its structure.
Use a concrete type when only one type is valid; generics add complexity that isn't worth it for a single use case.