Type constraints

Type constraints in Go define the set of allowed types for generic parameters using interfaces or type unions.

Type constraints

You write a function to find the maximum of two values. It works for integers. Then you need it for floats. You copy the code, change int to float64, and run. A week later, you need it for custom structs that implement a comparison method. Copy-pasting code is a trap. Go generics let you write the function once, but the compiler needs to know what T is allowed to be. Type constraints are the rules that tell the compiler which types fit into your generic template.

Without constraints, a type parameter is any. That means the compiler allows you to pass a channel, a function, or a struct. If your function tries to add two values, the compiler can't guarantee the operation exists. Constraints tighten the scope. They define a type set that the compiler checks against every call site. If the argument type isn't in the set, the build fails.

Think of a constraint like a bouncer at a club. The club is your generic function. The bouncer checks IDs. If the ID matches the list, you get in. If not, the bouncer kicks you out. In Go, the bouncer is the compiler, and the ID list is the type constraint. The constraint ensures that every type used with your generic code supports the operations you need.

Constraints are compile-time guards. They vanish at runtime. The generated code has no overhead.

Defining constraints with interfaces

Constraints look like interfaces, but they behave differently. You define a constraint using the interface keyword, but you never assign a value to it. The compiler uses the constraint to build a type set. When you call a generic function, the compiler checks if the argument's type is in that set. If you try to use a constraint as a regular interface, the compiler stops you. Constraints are metadata for the type checker, not runtime types.

Here's the simplest constraint: an interface that lists allowed types.

// Number defines a constraint for numeric types.
// The ~ prefix allows underlying types, not just the named type.
type Number interface {
    ~int | ~float64
}

// Sum adds a slice of numbers and returns the total.
// T is constrained to Number, so the compiler guarantees T supports addition.
func Sum[T Number](nums []T) T {
    var total T // Zero value of T works because all numbers have a zero value.
    for _, n := range nums {
        total += n // Operator + is valid because T is constrained to numeric types.
    }
    return total
}

The Number constraint uses a union of types. The | operator combines ~int and ~float64. The ~ prefix is crucial. It means "this type or any type whose underlying type is this." Without ~, the constraint would only accept the exact types int and float64. With ~, it also accepts type MyInt int or type Celsius float64.

The any keyword is just an alias for interface{}. Use any in constraints for readability. The community prefers any over the empty interface syntax in generic contexts.

Constraints are contracts. Break the contract and the compiler breaks the build.

The tilde and underlying types

The tilde operator expands a constraint to include custom types. Go allows you to define new types based on existing ones. type MyInt int creates a distinct type. MyInt is not int. They have the same underlying representation, but the compiler treats them as different.

If you write a constraint with int, only int satisfies it. MyInt fails. If you write ~int, both int and MyInt satisfy it. This distinction matters when you want your generic code to work with domain-specific types.

// WithoutTilde shows why the tilde matters.
// This constraint only accepts the exact type int.
type ExactInt interface {
    int
}

// WithTilde accepts int and any type with underlying type int.
type IntLike interface {
    ~int
}

type MyInt int

func processExact[T ExactInt](v T) {
    // Works for int, fails for MyInt.
}

func processLike[T IntLike](v T) {
    // Works for both int and MyInt.
}

If you call processExact(MyInt(5)), the compiler rejects this with an error stating that MyInt does not implement the ExactInt constraint. The compiler sees MyInt as a different type. If you call processLike(MyInt(5)), it compiles. The underlying type of MyInt is int, which matches ~int.

Use the tilde when you want flexibility. Skip it when you need strictness. The tilde is the difference between rigid and flexible. Use it when you want custom types to play along.

Methods in constraints

Constraints can include methods. If you add a method to a constraint, any type satisfying the constraint must implement that method. This lets you combine type requirements with behavior requirements.

// StringerInt constrains T to be an int-like type that also has a String method.
type StringerInt interface {
    ~int
    String() string
}

// LogInt prints the value using its String method.
func LogInt[T StringerInt](v T) {
    // v.String() is safe because the constraint requires the method.
    fmt.Println(v.String())
}

The StringerInt constraint requires two things. The type must have an underlying type of int. The type must have a String() string method. A plain int fails because it doesn't have a String method. A type MyInt int with a String method succeeds.

You can mix types and methods in any combination. The compiler builds a type set that includes all types matching the union of types and all types implementing the method set. This is powerful for library code that needs specific operations on custom types.

Constraints are compile-time guards. They vanish at runtime. The generated code has no overhead.

The comparable constraint

The comparable constraint is built into the language. It represents all types that support == and !=. This includes basic types, structs with comparable fields, pointers, and interfaces. You cannot define comparable yourself. It's a primitive constraint. Use it whenever you need map keys or equality checks.

// Unique returns a slice with duplicate values removed.
// The comparable constraint ensures T can be used as a map key.
func Unique[T comparable](items []T) []T {
    seen := make(map[T]struct{}) // Empty struct uses zero memory for values.
    var result []T
    for _, item := range items {
        if _, ok := seen[item]; !ok {
            seen[item] = struct{}{} // Mark item as seen.
            result = append(result, item)
        }
    }
    return result
}

The comparable constraint is essential for algorithms that rely on equality. If you try to use a slice or map as a type parameter without comparable, and you use it as a map key, the compiler rejects this with an error stating that the type is not comparable. Slices and maps are not comparable in Go, so they don't satisfy comparable.

Public names start with a capital letter. Private start lowercase. Constraints follow the same rule. Addable is public, addable is private. Name constraints descriptively. Number is better than N. Stringer is better than S.

Realistic example: Grouping by key

Real applications often combine multiple constraints. This function groups a slice by a key. The key type K must be comparable. The value type V can be anything.

// GroupBy groups items by a key function.
// K must be comparable to use as a map key.
// V is any type.
func GroupBy[K comparable, V any](items []V, keyFn func(V) K) map[K][]V {
    result := make(map[K][]V)
    for _, item := range items {
        k := keyFn(item)
        result[k] = append(result[k], item)
    }
    return result
}

The GroupBy function takes two type parameters. K is constrained to comparable because it becomes a map key. V is constrained to any because the function doesn't care about the value type. The keyFn argument extracts the key from each value. This pattern is common in data processing.

Gofmt handles constraint formatting. It aligns the type list. Don't fight it. Most editors run gofmt on save. Trust the tool.

Type sets are the engine. Interfaces are the syntax. Understand the set to master the constraint.

Pitfalls and errors

Constraints can be tricky. The most common mistake is forgetting the tilde. If you define a constraint with int and pass a custom type, the compiler rejects this with an error saying the type does not satisfy the constraint. Add ~ to fix it.

Another pitfall is using a constraint as a value. You cannot create a variable of type Number and assign an int to it. The compiler rejects this with an error that the constraint cannot be used as a value type. Constraints are only for type parameters.

Method sets in constraints can also cause confusion. If you add a method to a constraint, the type must implement it. If you have type MyInt int without a String method, it fails a constraint that requires String(). The compiler rejects this with an error stating the method is missing.

Constraints are compile-time guards. They vanish at runtime. The generated code has no overhead.

When to use constraints

Use any when the function works with any type and doesn't rely on specific operations. Use comparable when you need to use the type as a map key or compare it with ==. Use a union of types like int | float64 when you need to perform operators like + or - that only work on specific types. Use the ~ prefix when you want to allow custom types that share an underlying type, like type Celsius float64. Use a method-based constraint when the function needs to call a specific method, like String() or Read(). Use a type set with both types and methods when you need a custom type that also implements a behavior.

Use the right constraint. any is lazy. comparable is precise. Unions are specific. Pick the tightest fit.

Where to go next