Skip to Content
Course content

61: Generic Constraints and Interfaces

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've been thinking about how to make my utility libraries more reusable. I often find myself writing the same "Find Max" logic for int, then float64, and then some custom Price type. Naturally, I thought: "I'll just use generics and call it a day."

The "any" wall

My first instinct was to keep it as open as possible. I used any because, in my head, any type could potentially be compared. Let's look at what happened when I tried to compile this:

package main

import "fmt"

func Max[T any](a, b T) T {
    if a > b {
        return a
    }
    return b
}

func main() {
    fmt.Println(Max(10, 20))
}

The compiler immediately shut me down. It told me that the operator > is not defined for the type T. This makes sense if you think about it—any could be a struct, a map, or a channel. You can't use the "greater than" operator on a map. Go is being strict here to save us from a runtime panic.

The trap of "comparable"

Okay, so any is too broad. Go provides a built-in constraint called comparable. I figured, "This is exactly what I need." I swapped any for comparable and tried again:

func Max[T comparable](a, b T) T {
    if a > b {
        return a
    }
    return b
}

Same error. I was confused for a second, but then I remembered that in Go, comparable specifically means the type supports == and !=. It does not mean it supports ordered comparisons like < or >. A struct is comparable if all its fields are comparable, but that doesn't mean one struct is "greater than" another.

Defining our own boundaries

Now we're getting to the real meat of generic constraints. If the language doesn't provide a built-in "Ordered" constraint, I can build my own using an interface. But I don't want a behavior-based interface (like "has a Compare method"); I want a type-set. I want to tell Go: "T can be any one of these specific types."

I'll define a Number constraint using the pipe | symbol, which acts as a union:

type Number interface {
    int | int64 | float64
}

func Max[T Number](a, b T) T {
    if a > b {
        return a
    }
    return b
}

This works perfectly. By listing the types in the interface, I'm telling the compiler, "Trust me, whatever T ends up being, it's guaranteed to be one of these, and all of these support the > operator." It's a powerful pattern, though it can get verbose if you want to support every single numeric type (which is why the golang.org/x/exp/constraints package exists, but it's better to understand how it works under the hood first).

When behavior beats type sets

But wait—what if I have a custom type, like a Version struct that holds a major and minor number? I can't add a struct to a type-set union. The | operator only works with basic types.

This is where we pivot from "type constraints" to "behavior constraints." I'll define an interface that requires a specific method. If a type implements that method, it's allowed in my generic function.

type Comparable interface {
    Compare(other Comparable) int // returns -1, 0, or 1
}

func MaxValue[T Comparable](a, b T) T {
    if a.Compare(b) > 0 {
        return a
    }
    return b
}

Notice the subtle shift. With the Number constraint, I was using language operators (>). With the Comparable constraint, I'm using a method call. You choose the constraint based on whether you need the type to be something (type set) or do something (method set). Using both in the same project is common; it just depends on whether you're dealing with primitives or complex objects.




📋 Practical Task

Build a Generic "Most Frequent Element" Finder

Create a function called MostFrequent[T comparable](slice []T) T. This function should take a slice of any comparable type and return the element that appears most often.

  • Constraint: Use the comparable constraint because you will need to use the elements as keys in a map to track their frequency.
  • Requirement: The function should work equally well for a slice of strings []string{"apple", "banana", "apple"} and a slice of integers []int{1, 2, 2, 3}.
  • Edge Case: If the slice is empty, you can either return the zero value of T or handle it via a second bool return value (like (T, bool)).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.