31.5 Type Sets: Union Constraints with ~
Alright, let’s talk about the tilde (~). This little squiggle is one of the most elegant and simultaneously confusing additions to Go’s type system. It exists to solve a very specific, very real problem that emerges the moment you start writing generic functions with constraints like int.
Imagine you write this perfectly reasonable function:
func PrintInts[T int](values []T) {
for _, v := range values {
fmt.Println(v)
}
}
You try to use it, and immediately hit a wall.
func main() {
ints := []int{1, 2, 3}
PrintInts(ints) // Works fine.
type MySpecialInt int
myInts := []MySpecialInt{4, 5, 6}
PrintInts(myInts) // Compiler Error: MySpecialInt does not satisfy int
}
This feels absurd, right? MySpecialInt is literally an int underneath; it has all the same methods and operations. You didn’t create a new type to be difficult, you did it for type safety and clarity in your domain logic (e.g., type UserID int). But the generic function, with its constraint of T int, can’t see past the name. It’s pedantic to a fault.
This is the problem the ~ operator was born to solve.
What ~T Actually Means
The tilde is read as “underlying type.” The constraint ~int doesn’t mean “the type int.” It means “any type whose underlying type is int.”
When you write type MySpecialInt int, the underlying type of MySpecialInt is int. This is a core concept in Go’s type system that generics finally leveraged. So, by changing our function to use ~int, we welcome all these named types into the fold.
// This now accepts []int, []MySpecialInt, or a slice of any type with underlying type int
func PrintInts[T ~int](values []T) {
for _, v := range values {
fmt.Println(v)
}
}
type MySpecialInt int
type AnotherInt MySpecialInt // Underlying type: MySpecialInt -> int
func main() {
ints := []int{1, 2, 3}
myInts := []MySpecialInt{4, 5, 6}
anotherInts := []AnotherInt{7, 8, 9}
PrintInts(ints) // OK
PrintInts(myInts) // OK
PrintInts(anotherInts) // Also OK, because AnotherInt's underlying type is still int.
}
This is the primary and most important use case for ~: making your generic functions work seamlessly with the common Go practice of creating named types from built-ins.
The Critical Caveat: It’s About Underlying Types, Not Interfaces
This is the part that trips people up. You cannot use ~ with just any type. The specification is very clear: the type after the tilde must be itself an underlying type. This effectively means it cannot be an interface type or a type parameter; it must be a “proper” type like int, string, []byte, or a struct you’ve defined.
This code is illegal and will not compile:
// This is ILLEGAL. error will be: "invalid use of ~ (underlying type of error is interface)"
func PrintErrors[T ~error](errors []T) {
for _, err := range errors {
fmt.Println(err.Error())
}
}
Why? Because the underlying type of error is itself—it’s an interface. The ~ operator is a tool for unifying concrete types, not for creating hierarchies of interfaces. For interfaces, you just use the interface itself as a constraint.
// Correct: use the interface type directly, without ~
func PrintErrors[T error](errors []T) {
for _, err := range errors {
fmt.Println(err.Error())
}
}
Using ~ in Union Constraints
The ~ operator truly shines when combined with union constraints (|). This allows you to define a constraint that accepts a whole family of types based on their underlying types.
Let’s say you want a function that works for any integer type, whether signed, unsigned, or a named type derived from them.
// Integer is a constraint that matches any type with an underlying type that is
// one of the built-in integer types.
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
func AddTen[T Integer](value T) T {
return value + 10
}
type MyInt64 int64
type ID uint64
func main() {
a := AddTen(5) // T is int
b := AddTen(MyInt64(22)) // T is MyInt64, which has underlying type int64
c := AddTen(ID(100)) // T is ID, which has underlying type uint64
}
Without the ~ in the Integer constraint, the function would reject MyInt64 and ID, making it far less useful. This pattern is used extensively in the new standard library packages like golang.org/x/exp/constraints.
Best Practices and When to Reach for ~
Be Intentional: Don’t just slap
~on everything. Use it when you have a genuine need to support named types derived from the built-in types you’re constraining. If your function logic only makes sense for the exact typestringand would break with atype Password string, then don’t use~string.It’s for Constraints, Not Code: Remember,
~is only valid in a type parameter constraint (the part inside theinterface{}). You cannot use it in your function logic. Inside the functionPrintInts[T ~int], the typeTis whatever was passed in (MySpecialInt), notint. You can’t assign a value of typeintto a variable of typeTwithout a conversion.Underlying Type Chains: The compiler resolves underlying types through multiple aliases. If you have
type A int; type B A; type C B, the underlying type ofCis stillint. The~intconstraint will match it.
The ~ is a testament to Go’s pragmatism. It’s a minimal, powerful syntax that solves a real-world problem without adding overwhelming complexity. It respects how Go programmers already use types while unlocking the full potential of generics. Use it wisely.