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

how interface{} crept through my codebase and what it cost

A look at how the empty interface quietly spread through a Go service, and the type-safety I gave up to make it stop.

A close-up of code on a screen

interface{} is the easiest thing in Go to reach for and the hardest to put back. It accepts anything, which feels like flexibility right up until the moment you realise you've thrown away every guarantee the compiler was offering you for free. I have a service that learned this the slow way, and I'm the one who taught it.

It started innocently. I had a config loader that needed to handle a handful of value types, strings, ints, the odd nested map, and rather than model that properly I stored everything as map[string]interface{}. Job done, ship it. The trouble with interface{} is that it's contagious. Once one function returns it, the caller either type-asserts immediately or passes the empty interface along, and most of the time, under deadline, you pass it along. Six months later the type was threaded through a dozen functions and every single read site looked like this:

v, ok := cfg["timeout"].(int)
if !ok {
    return fmt.Errorf("timeout: expected int, got %T", cfg["timeout"])
}

A developer's screen mid-edit

Multiply that by every field, every nested level, and you've reinvented dynamic typing inside a statically typed language, complete with the runtime panics you were promised you'd never see. The first time a cfg["retries"].(int) panicked in production because someone had written retries: "3" in YAML, with the quotes, I knew exactly whose fault it was.

The bit that actually hurt

The regret isn't the verbosity. It's that the compiler had stopped helping me. With a proper struct, a typo in a field name is a build error. With a string-keyed map of empty interfaces, a typo is a missing key at runtime, in the unlucky code path, on the box you can't easily reach. I'd traded compile-time certainty for the convenience of not writing a struct, and the bill came due in pages and panics rather than red squiggles in the editor.

The fix was unglamorous. I defined the config as an actual struct and let the YAML library do the work:

type Config struct {
    Timeout time.Duration `yaml:"timeout"`
    Retries int           `yaml:"retries"`
    Backend BackendConfig `yaml:"backend"`
}

Now a wrong type in the YAML fails at load with a clear unmarshalling error, the field names are checked by the compiler, and the read sites are just cfg.Timeout. No assertions, no , ok, no %T in an error string.

When it's actually fine

I'm not going to pretend interface{} has no place. It's the right tool when you genuinely don't know the type ahead of time and can't: a generic JSON blob you're proxying, a logging helper that takes ...interface{}, the boundary where Go meets the formless outside world. The standard library uses it exactly there and nowhere it doesn't have to. The mistake is using it as a substitute for thinking about your types, which is what I did, because thinking about types is work and interface{} is right there.

The rule I've settled on: an empty interface is allowed at the edges of a program, where data arrives shapeless, and it must be converted to a real type before it gets one function deep. The moment you find yourself passing interface{} between two functions you wrote, stop. You've found a struct you haven't written yet.