Go's Generic Type Sets: Supercharge Your Code with Flexible, Type-Safe Magic

Explore Go's generic type sets: Enhance code flexibility and type safety with precise constraints for functions and types. Learn to write powerful, reusable code.

Go's Generic Type Sets: Supercharge Your Code with Flexible, Type-Safe Magic

Go’s generic type sets are a game-changer for writing flexible and type-safe code. They’re like a supercharged version of type constraints, giving us more control over what types can be used in our generic functions and types.

I’ve been playing around with type sets, and I’m impressed by how much more precise we can be with our generics. It’s not just about allowing or disallowing types anymore; we can create intricate rules that define exactly what combinations of types and methods our generics can work with.

Let’s start with the basics. Type sets let us combine multiple interfaces and types to create complex constraints. Here’s a simple example:

type Number interface {
    int | float64
}

func Sum[T Number](numbers []T) T {
    var sum T
    for _, n := range numbers {
        sum += n
    }
    return sum
}

In this code, we’re saying that our Sum function can work with slices of either int or float64. It’s a small thing, but it’s already more expressive than what we could do before.

But we can get much more specific. Say we want a function that works with any type that has both a String() and a Len() method. We can do that like this:

type Stringable interface {
    String() string
}

type Lengthable interface {
    Len() int
}

type StringAndLength interface {
    Stringable
    Lengthable
}

func ProcessStringyThing[T StringAndLength](thing T) {
    fmt.Printf("This thing is %s and has length %d\n", thing.String(), thing.Len())
}

This ProcessStringyThing function will only accept types that have both String() and Len() methods. It’s a powerful way to ensure that our generic functions are working with exactly the types we expect.

One of the coolest things about type sets is how they let us work with comparable types. In Go, not all types are comparable (meaning they can be used with == and !=). But with type sets, we can constrain our generics to only work with comparable types:

func FindDuplicates[T comparable](slice []T) []T {
    seen := make(map[T]bool)
    var duplicates []T
    for _, item := range slice {
        if seen[item] {
            duplicates = append(duplicates, item)
        }
        seen[item] = true
    }
    return duplicates
}

This function will work with any slice of comparable types, but won’t compile if you try to use it with a slice of non-comparable types. It’s a great way to catch errors at compile-time rather than runtime.

Type sets also shine when we’re working with numeric types. We can create constraints that work with any numeric type, or just integers, or just floating-point numbers:

type Number interface {
    int | int8 | int16 | int32 | int64 | float32 | float64
}

type Integer interface {
    int | int8 | int16 | int32 | int64
}

type Float interface {
    float32 | float64
}

func Average[T Number](numbers []T) float64 {
    var sum float64
    for _, n := range numbers {
        sum += float64(n)
    }
    return sum / float64(len(numbers))
}

This Average function will work with slices of any numeric type, converting everything to float64 for the calculation.

But where type sets really start to show their power is in more complex scenarios. Let’s say we’re building a generic data structure, like a priority queue. We might want to constrain it to types that are both comparable (so we can check for equality) and ordered (so we can compare priorities):

type Ordered interface {
    Integer | Float | ~string
}

type Comparable interface {
    comparable
}

type OrderedAndComparable interface {
    Ordered
    Comparable
}

type PriorityQueue[T OrderedAndComparable] struct {
    items []T
}

func (pq *PriorityQueue[T]) Push(item T) {
    pq.items = append(pq.items, item)
    pq.bubbleUp(len(pq.items) - 1)
}

func (pq *PriorityQueue[T]) Pop() T {
    if len(pq.items) == 0 {
        panic("Queue is empty")
    }
    item := pq.items[0]
    lastIndex := len(pq.items) - 1
    pq.items[0] = pq.items[lastIndex]
    pq.items = pq.items[:lastIndex]
    pq.bubbleDown(0)
    return item
}

func (pq *PriorityQueue[T]) bubbleUp(index int) {
    for index > 0 {
        parentIndex := (index - 1) / 2
        if pq.items[parentIndex] <= pq.items[index] {
            break
        }
        pq.items[parentIndex], pq.items[index] = pq.items[index], pq.items[parentIndex]
        index = parentIndex
    }
}

func (pq *PriorityQueue[T]) bubbleDown(index int) {
    for {
        minIndex := index
        leftChild := 2*index + 1
        rightChild := 2*index + 2

        if leftChild < len(pq.items) && pq.items[leftChild] < pq.items[minIndex] {
            minIndex = leftChild
        }
        if rightChild < len(pq.items) && pq.items[rightChild] < pq.items[minIndex] {
            minIndex = rightChild
        }

        if minIndex == index {
            break
        }

        pq.items[index], pq.items[minIndex] = pq.items[minIndex], pq.items[index]
        index = minIndex
    }
}

This priority queue will work with any type that’s both ordered and comparable. That means we can use it with integers, floats, and strings, but not with complex numbers or structs (unless we define custom ordering for them).

Type sets also give us more flexibility when working with user-defined types. The ~ operator lets us include all types with a given underlying type. For example:

type MyInt int

type Number interface {
    ~int | ~float64
}

func Double[T Number](n T) T {
    return n + n
}

func main() {
    var myInt MyInt = 5
    fmt.Println(Double(myInt))  // This works!
}

Without the ~, our Double function wouldn’t work with MyInt. But with it, we’re saying “this works with int and any type based on int”.

One area where type sets really shine is in creating safe mathematical operations. We can define constraints that ensure our math functions only work with types that support the operations we need:

type Addable interface {
    Integer | Float | ~string
}

type Multipliable interface {
    Integer | Float
}

func Add[T Addable](a, b T) T {
    return a + b
}

func Multiply[T Multipliable](a, b T) T {
    return a * b
}

func main() {
    fmt.Println(Add(5, 3))          // Works
    fmt.Println(Add(3.14, 2.86))    // Works
    fmt.Println(Add("Hello, ", "World!"))  // Works
    fmt.Println(Multiply(5, 3))     // Works
    fmt.Println(Multiply(3.14, 2))  // Works
    // fmt.Println(Multiply("Hello", 3))  // This would not compile
}

This approach allows us to create type-safe mathematical operations that work with a wide range of types, while still catching type-related errors at compile time.

Type sets also open up new possibilities for working with channels in a generic way. We can create functions that work with channels of any type, or only channels of certain types:

type Sendable interface {
    any
}

type Receivable interface {
    any
}

func SendAll[T Sendable](ch chan<- T, items ...T) {
    for _, item := range items {
        ch <- item
    }
}

func ReceiveAll[T Receivable](ch <-chan T) []T {
    var result []T
    for item := range ch {
        result = append(result, item)
    }
    return result
}

func main() {
    intChan := make(chan int, 5)
    SendAll(intChan, 1, 2, 3, 4, 5)
    close(intChan)
    fmt.Println(ReceiveAll(intChan))

    strChan := make(chan string, 3)
    SendAll(strChan, "Hello", "World", "!")
    close(strChan)
    fmt.Println(ReceiveAll(strChan))
}

These functions will work with channels of any type, making it easy to write generic channel operations.

Type sets also allow us to create more expressive interfaces for our types. We can define interfaces that require a type to implement multiple other interfaces:

type Stringer interface {
    String() string
}

type Loggable interface {
    Log()
}

type StringerAndLoggable interface {
    Stringer
    Loggable
}

func ProcessThing[T StringerAndLoggable](thing T) {
    fmt.Println("Processing:", thing.String())
    thing.Log()
}

type MyType struct{}

func (m MyType) String() string {
    return "I'm a MyType"
}

func (m MyType) Log() {
    fmt.Println("Logging MyType")
}

func main() {
    ProcessThing(MyType{})
}

This approach allows us to create very specific requirements for our generic functions, ensuring that we’re working with exactly the types we expect.

One of the most powerful aspects of type sets is how they let us create constraints based on method sets. We can define interfaces that require types to have specific methods, and use these in our generic functions:

type Walkable interface {
    Walk()
}

type Swimmable interface {
    Swim()
}

type Amphibious interface {
    Walkable
    Swimmable
}

func TakeATrip[T Amphibious](creature T) {
    fmt.Println("Time for a walk!")
    creature.Walk()
    fmt.Println("Now for a swim!")
    creature.Swim()
}

type Frog struct{}

func (f Frog) Walk() {
    fmt.Println("The frog hops along")
}

func (f Frog) Swim() {
    fmt.Println("The frog swims gracefully")
}

func main() {
    TakeATrip(Frog{})
}

This allows us to write functions that work with any type that has the required methods, without needing to know the specific type at compile time.

Type sets also give us new ways to work with slices and maps in a generic way. We can create functions that operate on slices or maps of any type that meets certain criteria:

type Mappable interface {
    comparable
}

func Keys[K Mappable, V any](m map[K]V) []K {
    keys := make([]K, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

type Sortable interface {
    Integer | Float | ~string
}

func SortSlice[T Sortable](s []T) {
    sort.Slice(s, func(i, j int) bool {
        return s[i] < s[j]
    })
}

func main() {
    m := map[string]int{"a": 1, "b": 2, "c": 3}
    fmt.Println(Keys(m))

    s := []int{3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5}
    SortSlice(s)
    fmt.Println(s)
}

These functions will work with maps and slices of any appropriate type, making it easy to write generic utility functions.

In conclusion, Go’s generic type sets are a powerful tool for creating flexible, type-safe code. They allow us to write functions and types that work with a wide range of types, while still maintaining strong type safety. Whether you’re building complex data structures, working with channels, or just trying to make your code more reusable, type sets give you the tools you need to write clear, concise, and correct Go code. As you continue to work with Go, I encourage you to explore the possibilities that type sets offer. They might just change the way you think about generic programming in Go.