Skip to Content
Course content

172: Generic Functions Revisited

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

A few years ago, I was reviewing a PR from a colleague who was building a high-performance caching layer. He had written a beautiful generic function to handle cache invalidation, using a type constraint like comparable for the keys. It worked perfectly for string and int. But the moment he introduced a custom type—type RequestID string—the compiler started screaming at him in a few specific edge cases involving interface assertions. He spent three hours questioning if he'd fundamentally misunderstood generics, only to realize he was fighting against the difference between a type and its underlying type.

When we first encounter generics in Go, we tend to stick to the basics: [T any] or [T comparable]. That gets you through 80% of your needs. But as you start building real-world libraries or complex internal frameworks, you'll find that those broad constraints are often too blunt. You'll realize that Go's type system is stricter than you remember, and you'll need to revisit how you define what T actually is.

The Magic of Type Approximation

The most common "gotcha" when revisiting generic functions is the difference between string and ~string. If you define a constraint as interface { string }, it only accepts the literal string type. If you have a type Email string, that type does not satisfy the constraint, even though it's just a string under the hood.

This is where the tilde (~) comes in. By using ~string, you are telling Go: "I don't care if this is exactly a string, as long as its underlying type is a string." I've seen this trip up even senior devs because it's a subtle distinction that only matters when you start creating domain-specific types.

type Numeric interface {
    ~int | ~float64
}

func Sum[T Numeric](nums []T) T {
    var total T
    for _, v := range nums {
        total += v
    }
    return total
}

type Price int // Custom type

func main() {
    prices := []Price{10, 20, 30}
    // This only works because of the ~ in the Numeric interface
    fmt.Println(Sum(prices)) 
}

Composition and Custom Constraints

Another area where we often need to refine our approach is when comparable isn't enough. comparable is great for using a type as a map key, but it doesn't help you if you need to perform a specific action on that type—like comparing two versions of a config file or checking if a user object is "equal" based on an ID rather than every single field.

The professional way to handle this is by composing constraints. Instead of relying on built-in constraints, you create an interface that combines a type set (the ~type part) with a method set. This allows your generic function to remain flexible while still guaranteeing that the type T has the behavior you need.

I usually recommend this pattern when you find yourself writing any and then using a type switch inside your function. If you're type-switching inside a generic function, you're usually defeating the purpose of generics. Instead, move that requirement into the constraint.

type Identifiable interface {
    comparable
    ID() string
}

func FindDuplicate[T Identifiable](items []T) []T {
    seen := make(map[string]T)
    var dups []T

    for _, item := range items {
        id := item.ID()
        if _, exists := seen[id]; exists {
            dups = append(dups, item)
        }
        seen[id] = item
    }
    return dups
}

Notice how Identifiable requires the type to be comparable AND have an ID() method. This is a powerful pattern because it keeps your logic clean and pushes the requirement onto the type definition, rather than the function implementation.




📋 Practical Task

Implement a Generic "UniqueBy" Filter

In this exercise, you will build a utility function that filters a slice of any type, removing duplicates based on a specific property of that type, rather than the whole object.

Requirements:

  • Define a constraint interface called Keyable that requires a method GetKey() string.
  • Implement a generic function UniqueBy[T Keyable](input []T) []T.
  • The function should return a new slice containing only the first occurrence of each element based on the value returned by GetKey().
  • Create a custom type User with fields ID int and Name string. Implement the GetKey() method for User to return the ID as a string.
  • Test your function with a slice of User structs that contains multiple users with the same ID but different names (simulating stale data).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.