The label versus the material
You write a generic function to add two numbers. You test it with int. It works. You create a custom type type Age int to represent ages. You pass Age to your function. The compiler rejects it. You stare at the screen. The function is generic. Age is an int. Why does Go refuse to play nice?
The answer lives in the tilde.
Go distinguishes between the shape of a value and the name you give it. The underlying type is the raw material. int is the material. When you write type Age int, you create a new label. Age is a distinct type from int. They share the same underlying material, but Go treats them as strangers. The tilde tells the compiler to look past the label and check the material. It means "match the underlying type."
How the tilde works
A type constraint in Go defines a set of allowed types. Without the tilde, the constraint matches types exactly. With the tilde, the constraint matches the underlying type.
Here's the simplest comparison: a function that rejects custom types versus one that accepts them.
package main
import "fmt"
// type Age int creates a new type.
// Age and int are different types, even though they share the same underlying representation.
type Age int
// Add accepts T where T is exactly int.
// This constraint rejects Age because Age is not int.
func Add[T int](a, b T) T {
return a + b
}
// AddTilde accepts T where the underlying type of T is int.
// This constraint accepts both int and Age.
func AddTilde[T ~int](a, b T) T {
return a + b
}
func main() {
// This works. int matches int exactly.
fmt.Println(Add(1, 2))
// This fails at compile time.
// The compiler rejects Age because it does not satisfy the constraint int.
// fmt.Println(Add(Age(10), Age(20)))
// This works. Age has an underlying type of int.
fmt.Println(AddTilde(Age(10), Age(20)))
}
The compiler checks constraints before generating code. When you call AddTilde(Age(10), Age(20)), the compiler peels back the name Age and finds int. The check passes. The compiler generates a version of AddTilde specialized for Age. At runtime, there is no tilde. The tilde is a compile-time instruction. The generated code operates on Age values directly. There is no overhead for the tilde check.
The tilde is a compile-time lens. It vanishes after the check.
Constraints are interfaces
A type constraint is syntactic sugar for an interface. When you write T ~int, you are defining an interface whose type set includes int and any type with an underlying type of int. This connects generics to Go's interface system. Interfaces in Go are satisfied implicitly. A type satisfies an interface if it has the required methods or matches the type set.
You can combine multiple underlying types using the union operator. This is common when building numeric utilities.
package main
import "fmt"
// Number accepts any type whose underlying type is int, int64, or float64.
// The union operator | combines multiple constraints.
// The tilde allows custom types like type Score int to pass.
type Number interface {
~int | ~int64 | ~float64
}
// Max returns the larger of two values.
// The constraint ensures T supports comparison operators.
func Max[T Number](a, b T) T {
if a > b {
return a
}
return b
}
type Score int
func main() {
// Max works with standard types.
fmt.Println(Max(10, 20))
// Max also works with custom types based on the underlying types.
fmt.Println(Max(Score(100), Score(95)))
}
The constraint Number defines a type set. Any type whose underlying type is int, int64, or float64 belongs to that set. Score qualifies because its underlying type is int. The function Max can use comparison operators because all types in the Number set support them.
When a constraint grows beyond a simple type or tilde, extract it into a named interface. Named constraints improve readability and allow documentation. The compiler treats named and inline constraints identically, but your teammates will thank you for the name.
Type sets define the universe of allowed types. The tilde expands that universe to include aliases.
Shape and behavior
The tilde matches the underlying type, not the method set. If Age has a method String() string, a constraint of ~int does not guarantee that method exists. The constraint only checks the shape, not the behavior added by the name. If you need methods, combine the tilde with an interface requirement.
This pattern appears when you need to access both the raw value and custom behavior.
package main
import "fmt"
// Countable requires an underlying type of int and a Count method.
// The tilde ensures the shape matches int.
// The method requirement ensures the type provides specific behavior.
type Countable interface {
~int
Count() int
}
// type ItemCount int is a named type.
type ItemCount int
// Count returns the integer value wrapped by the named type.
func (c ItemCount) Count() int {
return int(c)
}
// ProcessCountable works with any type satisfying Countable.
// It can access the Count method and treat the value as an int.
func ProcessCountable[T Countable](t T) {
// Access the method defined in the constraint.
fmt.Println("Count:", t.Count())
// Access the underlying value.
// Conversion is needed because T is not exactly int.
fmt.Println("Value:", int(t))
}
func main() {
var count ItemCount = 42
ProcessCountable(count)
}
The constraint Countable demands two things. The type must have an underlying type of int, and it must have a Count method. ItemCount satisfies both. The function can call t.Count() because the constraint guarantees the method. It can also convert t to int because the tilde guarantees the underlying type.
The tilde guarantees the shape. Methods guarantee the behavior. Use both when you need both.
Pitfalls and errors
The tilde has strict rules. You cannot use it with interfaces. The underlying type of an interface is the interface itself. Writing ~io.Reader makes no sense. The compiler catches this immediately with invalid type constraint io.Reader: cannot use ~ with interface type. Use the interface name directly.
Forgetting the tilde when you need it produces a constraint error. If you define func Add[T int](a, b T) T and call it with Age, the compiler rejects the code with type Age does not implement constraint int. The error message tells you exactly what went wrong. The type Age is not in the type set defined by int. Adding the tilde fixes the issue.
Over-broad constraints invite misuse. A constraint of ~int | ~int64 | ~int32 | ~int8 | ~uint | ~uint64 | ~uint32 | ~uint8 | ~float64 | ~float32 accepts almost every numeric type. This might seem convenient, but it allows mixing types that should not mix. A function that accepts both signed and unsigned integers can produce surprising results when negative values appear. Restrict constraints to what the function actually needs.
Restrict constraints to what the function actually needs. A broad constraint invites misuse.
Decision
Use a bare type constraint like int when you want to restrict the function to exactly that type and reject custom aliases. Use a tilde constraint like ~int when you want to accept the base type and any named types built on it. Use an interface constraint like io.Reader when you care about behavior rather than the underlying representation. Use a combined constraint like ~int | ~int64 when you need to support multiple numeric families while allowing custom types for each. Use any when the function does not depend on the type at all, such as a generic identity wrapper.