Ramblings of an aging IT geek
← Ramblings of an aging IT geek
golang

interface{} is a promise I keep breaking

Why reaching for the empty interface in Go almost always means I have skipped a decision I needed to make.

A close-up of source code on a dark editor

Every time I write interface{} I am making a deal with future me, and future me has never once thanked me for it. The deal is simple: I do not know what type this is yet, so I will figure it out later, at runtime, probably in production, probably at 02:00. Generics landed in 1.18 and I still catch myself doing it.

The empty interface is honest about one thing. It says "this could be anything." The problem is that the compiler then believes you. It steps back, folds its arms, and lets you pass a map[string]any where you meant a []byte, and you find out three function calls deep when a type assertion panics. The information was right there. I threw it away on purpose.

A blurred screen of code, the kind you stare at while a type assertion panics

Where it bites me most is decoding. I will pull some JSON into map[string]interface{} because the shape is "flexible", and then spend the next hour writing the saddest code in the language:

raw, ok := payload["config"].(map[string]interface{})
if !ok {
    return fmt.Errorf("config: expected object, got %T", payload["config"])
}
timeout, ok := raw["timeout"].(float64) // yes, float64, every number is float64
if !ok {
    return fmt.Errorf("timeout: not a number")
}

That float64 is the tell. JSON numbers come back as float64 whether you wanted an int or not, so now I am converting and bounds-checking and apologising. A struct with the right tags would have done all of this for me, told me at decode time exactly which field was wrong, and given me a value I could actually use. The "flexible" version is flexible the way a bag of loose change is flexible: technically it holds money.

The honest reasons I still reach for it:

  • I genuinely do not own the shape (a plugin boundary, a generic cache, a logging field). Fair enough. This is what it is for.
  • I am being lazy and have not yet decided what the shape is. This is the regret.
  • I have a function that handles "a few different types" and I cannot be bothered to write the generic constraint. This is also the regret, and since 1.18 it is a poorer excuse than it used to be.

The cache case is the one I will defend. A map[string]any keyed cache that stores results of mixed types is fine, because the type knowledge lives at the call site, and the assertion happens once, right where you have the context to handle it failing. The trouble starts when the any travels. It gets passed, stored, returned, and by the time someone asserts on it, nobody remembers what it was meant to be. Type information has a half-life and interface{} is where it goes to decay.

So the rule I have settled on, mostly for my own benefit: an empty interface is allowed to exist, but it is not allowed to travel. Assert at the boundary, name the concrete type, and let the rest of the program work with something the compiler can actually reason about. If I find an any more than one function away from where it was created, that is a smell, and it usually means I dodged a decision earlier and called it flexibility.

I will still write it tomorrow. But now at least I feel bad about it, which is the closest thing to discipline I have managed.