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

every time i reach for interface{} i live to regret it

A walk through the places I've used Go's empty interface, the runtime pain it caused, and how generics and a few honest concrete types let me delete most of it.

A code editor showing Go source

interface{} is Go's way of saying "I don't want to think about this type right now". And every single time I've taken it up on that offer, I've paid for the deferral later, usually at runtime, usually in production, usually at a time that was not convenient.

This is a post about the empty interface, the trouble it has caused me, and how much of it I've managed to delete now that we have generics. It is, mostly, a confession.

the appeal

The pitch is seductive. You have a function that does something generic in spirit: a cache, a serialiser, a pipeline stage. You don't want to write it five times for five types. So you write it once against interface{} (or any, post 1.18, same thing with a nicer name) and reach for a type assertion or a type switch inside.

func (c *Cache) Get(key string) (interface{}, bool) {
    v, ok := c.m[key]
    return v, ok
}

Lovely. One cache, holds anything. Then the call site:

v, ok := cache.Get("user:42")
if !ok {
    return nil, ErrNotFound
}
user := v.(*User) // and here is where it all goes wrong

That assertion is a promise you're making to the compiler that it cannot check. The day someone stores a User (value, not pointer) under one key and a *User under another, that line panics. Not at compile time when you'd notice. At runtime, on the one code path your tests didn't cover.

the costs, itemised

The empty interface doesn't just defer type checking, it scatters it. The knowledge of "what is actually in here" lives at every call site, not in one place the compiler can verify. So the costs compound.

The first is the panic I just described. A failed type assertion without the comma-ok form is a hard panic, and I have absolutely shipped those.

The second is allocation. Putting a value into an interface{} boxes it. For a hot path that shovels millions of small values through a generic channel or buffer, that boxing shows up in the profile as a wall of runtime.convT64 and friends, and the garbage collector earns its keep clearing up after you. I once halved the allocation rate of a metrics pipeline by removing exactly one chan interface{}.

A close-up of a terminal with program output

The third is the one that actually hurts: the type becomes undocumented. A function that returns interface{} tells the reader nothing. You have to go and read the implementation, or worse, run it, to learn what comes back. The signature, which should be the contract, has abdicated.

generics, and the deletions they enabled

Since 1.18 I've gone back through the worst offenders. The cache above becomes:

type Cache[T any] struct {
    m map[string]T
}

func (c *Cache[T]) Get(key string) (T, bool) {
    v, ok := c.m[key]
    return v, ok
}

Now the call site has no assertion at all:

user, ok := userCache.Get("user:42")

user is a *User, the compiler knows it, and the panic I used to ship is now a compile error if I get it wrong. The boxing is gone too, because the cache is specialised to the concrete type. Two problems deleted by changing a signature.

The honest caveat is that generics are not a universal replacement. The moment you genuinely need a heterogeneous container, a slice that really does hold mixed types, generics don't help and any is the correct tool. Configuration trees, JSON of unknown shape, plugin registries: these are legitimately dynamic and trying to force them into a type parameter just relocates the pain. The trick is telling the two apart.

when interface{} is actually right

So I haven't sworn it off. A few cases where any (or, better, a small purpose-built interface) is the right call:

  • Genuinely unknown data from outside the program: decoded JSON, config you'll validate at the edge.
  • The standard library's own patterns, like fmt's variadic ...any, where the whole point is to accept anything and reflect over it.
  • A boundary where you immediately validate and convert into a real type, keeping the any confined to one function rather than letting it leak through your APIs.

The rule I've settled on: an empty interface is allowed to exist for the length of one function, at the edge of the system, where untyped data arrives. It is not allowed to be the return type of anything I expect another part of the program to call. The further interface{} travels from the boundary, the more call sites have to know its secret, and the more places I've planted a panic for future me to find.

the regret, stated plainly

Most of my uses of the empty interface were not "I have genuinely dynamic data". They were "I couldn't be bothered to name the type, or to write the function twice, or to think". Generics removed the excuse for the second and third of those. For the first, the answer was always a properly named interface with the two methods I actually needed, not the empty one that promised everything and checked nothing.

So: name your types. Take the small up-front cost of saying what a function returns. The empty interface will let you skip that cost today and charge it back to you, with interest, on a Friday afternoon in production. I've paid that bill enough times to stop pretending it's free.