36.8 Avoid Premature Abstraction: Start with Concrete Types
Let’s be honest: you’ve been tempted. You see a function that takes a string and returns an int, and a little voice in your head whispers, “What if we need to handle other types later? We should make this generic now.” That voice is your inner architect, and while its intentions are noble, it’s often your enemy. In Go, the most powerful design tool is often the concrete type, and the most common design mistake is abandoning it too soon in favor of needless abstraction.
The mantra here is simple: write code for the problem you actually have, not the one you imagine you might have someday. This isn’t a philosophy of laziness; it’s one of pragmatism and deferred complexity. You can’t abstract a problem you don’t yet understand. By starting with concrete, real-world types, you build a working system first. The actual patterns of use and duplication emerge from that working code, showing you exactly what needs to be abstracted and, more importantly, how.
The Siren Song of interface{} (or any)
Go 1.18 gave us the glorious new any keyword, which is basically just interface{} in a fancy new suit. It’s incredibly useful for things like generic data structures (e.g., map[string]any for JSON). But it’s a trap for business logic.
// The Siren's Call: "This is so flexible!"
func ProcessData(data any) (any, error) {
// ...Now what? A type switch? Reflection?
switch v := data.(type) {
case string:
return len(v), nil
case int:
return v * 2, nil
default:
return nil, fmt.Errorf("unhandled type: %T", data)
}
}
Look at that mess. The function signature tells you nothing. What does it accept? What does it return? The caller has to guess or read a novel-length comment. The callee has to write a brittle type switch that gets more horrifying with every new type. You’ve traded compile-time type safety for runtime panic anxiety. You’ve abstracted too early, and all you’ve built is a liability.
The Concrete, Boring, Beautiful Alternative
Instead, just write the damn function for the type you need today.
// The Practical Reality: Clear, safe, and testable.
func ProcessString(s string) (int, error) {
if s == "" {
return 0, errors.New("empty string")
}
return len(s), nil
}
// Oh look, a new requirement! Let's write another function.
func ProcessInt(i int) (int, error) {
if i < 0 {
return 0, errors.New("negative integer")
}
return i * 2, nil
}
This isn’t duplication; it’s clarity. Each function is trivial to reason about, document, and test. The compiler is your partner, ensuring you can’t pass an int to ProcessString. When you finally have a dozen such functions and see the real pattern, then you can introduce an abstraction.
When the Pattern Emerges, Abstract
After writing ProcessString, ProcessInt, ProcessFloat, and ProcessUser, you notice a theme: they all take one argument, validate it, and transform it. Now you have the knowledge to create a meaningful abstraction.
// A well-defined interface based on actual use.
type Processor interface {
Process() (int, error)
}
// Now we can write a generic function that operates on the abstraction.
func Process(p Processor) (int, error) {
return p.Process()
}
// But crucially, the implementations are still concrete and separate.
type StringProcessor string
func (s StringProcessor) Process() (int, error) {
if string(s) == "" {
return 0, errors.New("empty string")
}
return len(s), nil
}
type IntProcessor int
func (i IntProcessor) Process() (int, error) {
if i < 0 {
return 0, errors.New("negative integer")
}
return int(i) * 2, nil
}
See the difference? The abstraction (Processor) was forced by the concrete code, not speculation. It’s lean, purposeful, and each implementation remains isolated and simple. The Process function is now genuinely generic and doesn’t care about the underlying type. This is how you use interfaces in Go: not as a starting point, but as a finishing tool.
The Rule of Three
A good heuristic is the Rule of Three. Don’t even think about extracting an interface until you’ve written the same concrete code three times. The first time is an occurrence. The second time might be a coincidence. The third time is a pattern. Let the repetition prove the need for abstraction. You’ll be shocked how often you never get to a third time, saving you from building an abstraction you never ended up needing. Your codebase will be smaller, clearer, and far easier to change. And that’s the whole point.