The union constraint trap
You write a function in JavaScript that adds two values. You pass numbers, and it works. You move to Go and want a generic function that handles any numeric type. You try to write a constraint like ~int | ~float64 directly in the type parameter list to say "accept anything that looks like an int or a float." The compiler rejects the code. Go does not support inline union expressions in type parameters. You cannot write func Add[T ~int | ~float64](a, b T) T.
Go forces you to define the union as an interface type first. This feels like extra syntax if you come from TypeScript or Python, where unions are first-class expressions. In Go, constraints are always interface types. The language reuses the interface mechanism to define type sets, keeping the type system unified. You define the allowed types inside an interface, then pass that interface as the constraint.
The restriction is intentional. Go designers avoided adding new syntax for constraints by leveraging existing interface features. An interface can embed types to form a type set. A type set acts like a union of types. The result is the same capability with a different shape. You write the union once as a named interface, then reuse it across functions.
Type sets are interfaces
A type set is a collection of types that a type parameter can match. You define a type set using an interface type. The interface lists the allowed types using the ~ operator for underlying types or bare type names for exact matches. Multiple types in a type set are separated by the pipe operator |.
// Number defines a type set that accepts any integer or floating-point type.
// The ~ operator matches the underlying type, so custom types based on int work too.
type Number interface {
~int | ~float64
}
This interface does not require methods. It only lists types. When you use Number as a constraint, the compiler checks if the argument's type is in the set. If you pass an int, it matches ~int. If you pass a float64, it matches ~float64. If you pass a string, the compiler rejects the call.
The interface name becomes the constraint. You pass the interface name in the type parameter list, not the union expression.
// Add takes two values of the same numeric type and returns their sum.
// The constraint T Number ensures T is a type from the Number type set.
func Add[T Number](a, b T) T {
return a + b
}
This pattern scales. You can define Signed, Unsigned, Float, or Complex interfaces and reuse them. The constraint is just a reference to the interface. The compiler resolves the type set at compile time. No runtime cost exists for the constraint check.
Constraints are compile-time gates. The runtime sees only concrete functions.
The ~ operator and underlying types
The ~ operator is the key to flexible constraints. It matches the underlying type of a value. Go allows you to define new types based on existing types. type Celsius int creates a new type Celsius with underlying type int. Without ~, a constraint of int would reject Celsius. With ~int, the constraint accepts Celsius because its underlying type is int.
This behavior enables domain-specific types to work with generic functions. You can define type Distance int and type Weight int. Both have underlying type int. A function constrained by ~int accepts both. The function preserves the type, so Add(Distance(10), Distance(5)) returns Distance, not int.
The ~ operator does not match different underlying types. ~int matches int, Celsius, and Distance. It does not match int32, int64, or float64. Each underlying type is distinct. If you want to support all integer sizes, you must list them all in the type set.
// AllInts includes every integer type in the standard library.
// You must list each size explicitly because ~int does not cover int32.
type AllInts interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
This explicit listing is a common friction point. Beginners assume ~int covers all integers. It does not. The type system treats int and int32 as separate underlying types. The constraint must enumerate every variant you want to support.
Type sets are precise. List every underlying type you intend to accept.
Minimal example
Here is the simplest working pattern. Define the interface, write the function, call it with concrete types.
package main
import "fmt"
// Number accepts int and float64, plus any type with those underlying types.
type Number interface {
~int | ~float64
}
// Add returns the sum of two numeric values of the same type.
func Add[T Number](a, b T) T {
return a + b
}
func main() {
// Call with ints. T resolves to int.
sum := Add(1, 2)
fmt.Println(sum) // prints: 3
// Call with floats. T resolves to float64.
sumF := Add(1.5, 2.5)
fmt.Println(sumF) // prints: 4
// Define a custom type based on int.
type Score int
score := Add(Score(10), Score(20))
fmt.Println(score) // prints: 30
}
The function works for int, float64, and Score. The return type matches the input type. The compiler generates separate versions of Add for each type used.
Run gofmt on your code. The indentation of the interface definition does not affect compilation, but consistent formatting keeps the codebase readable. Most editors run gofmt on save. Trust the tool.
Walk through: compile time and monomorphization
When you compile a program with generics, the compiler performs monomorphization. It generates a concrete copy of the generic function for each distinct type used at call sites. If you call Add(1, 2) and Add(1.5, 2.5), the compiler produces two functions: one for int and one for float64.
The constraint check happens during this process. The compiler verifies that int satisfies Number and that float64 satisfies Number. If you try to call Add("a", "b"), the compiler checks string against Number. The check fails. The compiler emits an error and stops. No binary is produced.
The generated functions contain no constraint checks. They are plain functions with concrete types. The Add for int looks like a normal function that takes two int arguments. The overhead of generics is zero at runtime. The cost is paid at compile time, and potentially in binary size if you use many types.
Monomorphization can increase binary size. If you use a generic function with ten different types, the compiler emits ten copies. For small functions, this is negligible. For large functions, the duplication adds bytes. Profile your binary if size matters. In most applications, the clarity of generics outweighs the size increase.
Constraints are compile-time gates. The runtime sees only concrete functions.
Realistic example: Sum with zero values
A common pattern is processing a slice of values. A Sum function iterates over a slice and accumulates the total. The function needs a zero value to initialize the accumulator. Go provides zero values for all types. For numbers, the zero value is 0. You can declare a variable of type T without initialization, and it starts at zero.
// Sum calculates the total of a slice of numeric values.
// It preserves the element type in the return value.
func Sum[T Number](vals []T) T {
// var total T initializes total to the zero value of T.
// For int, this is 0. For float64, this is 0.0.
var total T
for _, v := range vals {
total += v
}
return total
}
This function works for any type in the Number set. It works for []int, []float64, and []Score. The caller gets back the same type they passed in. The function body is identical for all types. The compiler generates the loop once per type.
The var total T idiom is standard Go. Do not write total := T(0). The zero value declaration is shorter and works for any type, including structs and slices. The constraint ensures T supports +=, so the accumulation is safe.
The constraints package lives in golang.org/x/exp, not the standard library. This signals that the Go team is still refining the exact set of useful constraints. For most projects, defining a local interface like Number is safer and more explicit. Importing x/exp pulls in experimental code that may change. Define your own constraints when the domain logic is specific.
Pitfalls: int32, mixed types, and methods
Generic constraints introduce specific failure modes. Understanding these prevents runtime surprises and compiler frustration.
The int32 trap
The constraint ~int does not match int32. This is the most common mistake. Developers write ~int expecting it to cover all integers. It only covers types with underlying type int. On 64-bit systems, int is 64 bits. int32 is 32 bits. They are different underlying types.
If you pass an int32 to a function constrained by ~int, the compiler rejects the call. The error message is direct.
The compiler rejects the call with
type int32 does not satisfy ~int (int32 is not assignable to int).
To support int32, add ~int32 to the type set. If you need all integer sizes, list them all. Use the AllInts pattern shown earlier. Do not assume ~int is a wildcard for integers.
Mixed type arguments
Generics require a single type parameter T to resolve to one concrete type. You cannot pass arguments of different types to a function with one type parameter.
// This call fails. T cannot be both int and float64.
// Add(1, 1.5)
The compiler cannot infer a single type that satisfies both arguments. It emits an error.
The compiler rejects mixed calls with
cannot infer T (mismatched types int and float64).
If you need to add an int and a float64, convert one to the other before calling. Or write a function that takes two type parameters, though that complicates the return type. Usually, explicit conversion is clearer.
Adding methods breaks type sets
If you add a method to a type set interface, the constraint changes. The type set becomes the intersection of the listed types and the types that implement the methods. Bare types like int and float64 do not implement methods. Adding a method excludes them.
// This constraint excludes int and float64 because they lack a String method.
type StringableNumber interface {
~int | ~float64
String() string
}
If you use StringableNumber as a constraint, int no longer matches. The compiler rejects Add[int]. Only types that have the underlying type int or float64 AND implement String() will match. This is rarely what you want.
If you need behavior, use a type switch inside the function. Do not add methods to a type set constraint that includes bare types. Type sets are for shape, not behavior. Add methods and you lose the types.
Decision matrix
Pick the right tool based on your logic and type requirements.
Use a type set interface when you need a generic function that works on a fixed list of types with identical logic.
Use a type switch when the logic differs significantly between types, such as formatting an int differently from a float.
Use any when you are building a container or cache that stores heterogeneous values and defers type checking to the caller.
Use separate non-generic functions when the type list is short and adding generics obscures the code without saving lines.