The whitelist for your generic types
You write a function to scale a configuration value. It needs to work for int, int64, and float64. You try interface{} and end up writing a massive type switch. You try a custom interface and realize built-in types don't implement interfaces by default. You need a way to tell the compiler: "This function accepts any type that is essentially a number."
That's what type sets are for. A type set is the collection of types a type parameter can represent. You define the set using a constraint, and the compiler enforces the boundary at compile time. If you pass a type outside the set, the build fails. There is no runtime overhead. The type set is a contract checked before your code runs.
Type sets and the ~ operator
A constraint looks like an interface, but it serves a different purpose. An interface constraint usually checks for methods. A type set constraint checks for underlying types. The ~ operator is the wildcard for "this type and its aliases."
When you write ~int, you are not just asking for int. You are asking for int and any type whose underlying type is int. If you define type MyInt int, the underlying type of MyInt is int. ~int matches both. Without the ~, the constraint matches only int exactly.
Go code uses type aliases heavily for documentation. type UserID int is clearer than int in a struct field. Type sets respect this culture. The ~ operator ensures your generics work with aliased types without forcing users to unwrap values.
Here's the simplest type set: allow integers or strings, including their aliases.
type StringOrInt interface {
// ~int matches int and type MyInt int.
// ~string matches string and type MyString string.
// | creates a union of the two type sets.
~int | ~string
}
// PrintType prints the value and its type name.
func PrintType[T StringOrInt](v T) {
// T is resolved to the concrete type at compile time.
// The compiler generates a specialized version for each call site.
fmt.Printf("Value: %v, Type: %T\n", v, v)
}
Type sets are compile-time contracts. The compiler enforces the whitelist.
How the compiler checks type sets
When you call PrintType(42), the compiler checks if int is in the type set of StringOrInt. It is. The compiler generates a version of PrintType specialized for int. If you call PrintType("hello"), the compiler checks string. string is in the set. A specialized version for string is generated.
If you call PrintType(true), the compiler checks bool. bool is not in the set. The compiler rejects the program with cannot use true (untyped bool constant) as T value in argument. The error happens at the call site. You fix it by changing the argument or widening the constraint.
The type set check is exhaustive. The compiler knows the underlying type of every type in the program. It compares the underlying type against the constraint. This is why type MyInt int works with ~int but fails with int. The underlying type of MyInt is int, so ~int matches. The exact type MyInt is not int, so int does not match.
Generics in Go are monomorphized. The compiler generates code for each type used. The type set restricts which types can trigger code generation. This keeps the generated code predictable and safe.
Realistic example: a numeric constraint
In real code, you often need to restrict generics to numbers. You want to do arithmetic, but you don't want to accept strings or structs. A numeric type set lists all the numeric types and their aliases.
Here's a type set for a math helper: restrict to all numeric types so you can do arithmetic safely.
type Numeric interface {
// ~int covers int and aliases like type Count int.
// ~uint covers uint and aliases.
// ~float64 covers float64 and aliases.
// The union includes all standard numeric types.
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
// Scale multiplies a numeric value by a factor.
func Scale[T Numeric](val T, factor float64) T {
// Convert to float64 for multiplication.
// All types in Numeric can convert to float64.
result := float64(val) * factor
// Cast back to T.
// The compiler knows T is numeric, so this is safe.
return T(result)
}
The Scale function works for int, int64, float32, and any alias like type Price float64. If you try Scale[bool](true, 2.0), the compiler complains with bool does not satisfy Numeric. The constraint prevents misuse.
The Numeric constraint is verbose, but it's explicit. Go doesn't have a built-in Number constraint because the language design prefers explicit lists over implicit categories. You write the list once, and the compiler enforces it everywhere.
Trust gofmt to align the union operators in long constraints. Most editors run gofmt on save, and it formats type sets with consistent indentation. Don't argue about alignment; let the tool decide.
Built-in constraints: comparable
Go provides one built-in type set: comparable. It represents all types that support == and !=. You use it when you need to compare values or use them as map keys.
// Deduplicate removes duplicates from a slice.
func Deduplicate[K comparable](items []K) []K {
// Use a map to track seen items.
// K must be comparable to be a map key.
seen := make(map[K]struct{})
var result []K
for _, item := range items {
if _, exists := seen[item]; !exists {
seen[item] = struct{}{}
result = append(result, item)
}
}
return result
}
The comparable constraint is essential for map keys. If you try to use a slice or map as a map key, the compiler rejects it with invalid map key type slice. Slices and maps are not comparable because their equality is defined by content, not identity. The comparable type set excludes them.
comparable is a type set, not a method constraint. You don't implement comparable. The compiler determines comparability based on the type structure. Primitives, structs with comparable fields, and pointers are comparable. Slices, maps, and functions are not.
comparable is the most used constraint in Go generics. If you write a function that uses a map, you almost certainly need it.
Pitfalls and compiler errors
Type sets have a few traps. The most common is forgetting the ~ operator. If you define type MyInt int and your constraint is int, the alias won't match. The compiler rejects the call with MyInt does not satisfy T. The fix is to use ~int in the constraint.
Another trap is mixing type sets with methods. You can combine them in a single constraint. interface { ~int; String() string } requires the type to be int-like AND have a String method. int is excluded because it has no methods. type MyInt int is included only if it defines String. This is powerful but tricky. If you add a method requirement, you exclude the base built-in types.
type StringableInt interface {
// ~int requires the underlying type to be int.
// String() string requires a method.
// int fails because it has no String method.
// type MyInt int works only if it defines String.
~int
String() string
}
// FormatInt formats a StringableInt.
func FormatInt[T StringableInt](v T) string {
// v.String() is available because of the method constraint.
return v.String()
}
If you pass int to FormatInt, the compiler complains with int does not satisfy StringableInt (missing method String). The type set allows int, but the method constraint blocks it. Both parts of the constraint must be satisfied.
Empty type sets are another issue. interface { ~int; ~string } requires a type to be both int-like and string-like. No type satisfies this. The constraint is valid syntax, but you can't instantiate it. The compiler warns with T has no possible types if you try to use it. Avoid constraints that combine mutually exclusive type sets.
The worst generic bug is the one that compiles but does the wrong thing. Type sets prevent this by narrowing the scope. If the constraint is too wide, you risk runtime panics. If it's too narrow, you lose flexibility. Find the balance.
Underlying types and structs
The underlying type of a struct is the struct itself, not its fields. type Point struct { X int; Y int } has underlying type struct { X int; Y int }. ~int does not match Point, even though it contains ints. Generics don't do duck-typing on fields.
If you want to match structs, you need to list the struct types explicitly or use a method constraint. interface { ~Point } matches Point and aliases like type MyPoint Point. It does not match struct { X int; Y int } unless you use ~struct { X int; Y int }, which is rarely useful.
This distinction matters when designing libraries. If your generic function needs to work with any struct that has an ID field, you can't use a type set. You need a method constraint or reflection. Type sets are for type families, not structural patterns.
Don't fight the type system. Wrap the value or change the design.
Decision matrix
Use a type set when you need to restrict a generic to a specific group of underlying types, like numbers or strings.
Use a method constraint when the behavior matters more than the type, such as requiring a Write method for an I/O helper.
Use any when the function treats the value as an opaque blob, like a cache that stores and retrieves without inspection.
Use a mixed constraint when you need both a type guarantee and a method, such as a numeric type that also implements a custom String method.
Use plain non-generic code when the type set is just one or two types: a type switch or function overloading is often clearer than a generic constraint.