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 ~

  1. 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 type string and would break with a type Password string, then don’t use ~string.

  2. It’s for Constraints, Not Code: Remember, ~ is only valid in a type parameter constraint (the part inside the interface{}). You cannot use it in your function logic. Inside the function PrintInts[T ~int], the type T is whatever was passed in (MySpecialInt), not int. You can’t assign a value of type int to a variable of type T without a conversion.

  3. 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 of C is still int. The ~int constraint 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.