Right, let’s talk about the bad old days. You know, the ones we’re all pretending to forget now that we have generics. Before Go 1.18, if you wanted to write a function that could handle multiple types, you were faced with a classic engineering trade-off: do the wrong thing fast, or do the wrong thing slowly. Your two main options were code duplication or the infamous interface{}.

Let’s say you wanted a simple function to find the maximum value in a slice. A simple task, right? Not if you needed it for int, float64, and string slices. Your first, most visceral reaction was to just write the same function three times.

The Copy-Paste Compromise

You’d end up with a codebase that looked like this:

// MaxInt returns the maximum value in a slice of ints.
func MaxInt(s []int) int {
    if len(s) == 0 {
        return 0 // What even *is* the max of nothing? Problem for later.
    }
    max := s[0]
    for _, v := range s[1:] {
        if v > max {
            max = v
        }
    }
    return max
}

// MaxFloat64 returns the maximum value in a slice of float64s.
func MaxFloat64(s []float64) float64 {
    if len(s) == 0 {
        return 0 // Yep, same problem.
    }
    max := s[0]
    for _, v := range s[1:] {
        if v > max {
            max = v
        }
    }
    return max
}

You get the picture. It’s a maintenance nightmare. The logic is identical, but the types are different. Every time you need to fix a bug (like, oh I don’t know, that whole “empty slice” catastrophe we just glossed over), you have to remember to fix it in three, four, or twelve different places. It’s tedious, error-prone, and frankly, a bit embarrassing.

The interface{} Siren Song

“So,” you’d think, “I’m a smart programmer. I’ll use interface{} and reflect! I’ll write it once!” This was the path of good intentions leading directly to a runtime panic. It looked something like this:

// MaxInterface returns the maximum value in a slice of... something.
// Good luck.
func MaxInterface(slice interface{}) (interface{}, error) {
    s := reflect.ValueOf(slice)
    if s.Kind() != reflect.Slice {
        return nil, fmt.Errorf("MaxInterface: not a slice, got %v", s.Kind())
    }
    if s.Len() == 0 {
        return nil, fmt.Errorf("MaxInterface: empty slice")
    }

    // We have to assume the elements are comparable. Hope is a strategy!
    max := s.Index(0).Interface()
    for i := 1; i < s.Len(); i++ {
        current := s.Index(i).Interface()
        // Here comes the real horror: a type switch that must handle every possible type.
        switch current.(type) {
        case int:
            if current.(int) > max.(int) {
                max = current
            }
        case float64:
            if current.(float64) > max.(float64) {
                max = current
            }
        // ... and so on for every type you want to support
        default:
            return nil, fmt.Errorf("MaxInterface: unhandled type %T", current)
        }
    }
    return max, nil
}

This is, to put it technically, a mess. It’s verbose, it’s slow (all those reflections and type assertions add up), and it loses all type safety. The compiler is utterly useless here. It has no idea what you’re putting into this function or what you’re getting out. You only find out if you passed a struct{} slice when your program crashes at runtime with a helpful panic about not being able to compare structs.

The interface{} solution is a classic case of doing more work to get less functionality. You traded compile-time safety for runtime flexibility, and the runtime flexibility wasn’t even that flexible! You had to manually add support for every single type you cared about. It was the worst of all worlds.

This was the core problem: we needed a way to write functions and data structures that were abstract over types without sacrificing type safety, performance, or readability. We needed the compiler to stay in the loop, to know that the max function for a []int returns an int, and for a []string returns a string. The solutions available to us were either brutally repetitive or dangerously loose. Generics, as we’ll see, were the language finally giving us a precise, first-class tool for the job.