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

every interface{} i ever wrote came back to bill me

A reckoning with the empty interface in Go, why it felt clever at the time, and how generics finally let me delete most of the type assertions it forced on me.

Go source code on a screen

Every interface{} I ever wrote eventually sent me a bill. The bill arrives months later, usually in the form of a runtime panic from a type assertion I was too clever to guard, in a code path I had convinced myself could never receive the wrong type. It always can. It always does.

I want to be fair to my past self, because the empty interface is genuinely seductive. You have a function that needs to handle a few different shapes of data, the types do not share a useful interface, and interface{} lets you say "anything goes" and get on with your life. It feels like flexibility. It is borrowing against the type system, and like all borrowing, the repayment is what gets you.

What it actually costs

The cost is that you have thrown away the one thing the compiler was offering to do for you for free, and you have to do it yourself, by hand, at runtime, forever. Every value that comes in through an interface{} has to be unpacked with a type assertion or a type switch before you can do anything with it.

func process(v interface{}) error {
    switch x := v.(type) {
    case string:
        return handleString(x)
    case int:
        return handleInt(x)
    default:
        return fmt.Errorf("unexpected type %T", v)
    }
}

That default case is the tell. Every empty interface eventually grows one, because you are now responsible for the failure mode the compiler used to handle. And the panic-prone version, the one without the comma-ok check, is even worse:

s := v.(string)   // panics at runtime if v is not a string

I wrote a lot of that second form when I was younger and more confident. It works right up until the day someone passes the function a value you did not anticipate, and then it does not fail at the boundary, it fails deep in the call stack with a panic that tells you nothing about who actually sent the wrong thing.

Source code with syntax highlighting on a dark editor

What generics changed

For years the honest defence of interface{} was that Go did not give you a better tool for genuinely generic code. If you wanted a function that worked over many types and could not name a shared interface, the empty interface was the language's answer, and grumbling about it was grumbling about Go itself.

That defence expired. Since 1.18 we have type parameters, and most of the places where I reached for interface{} to mean "some type I will figure out at runtime" actually meant "some type the caller knows and I should make the compiler track". Those are completely different statements, and generics let me say the second one.

func Map[T, U any](in []T, f func(T) U) []U {
    out := make([]U, len(in))
    for i, v := range in {
        out[i] = f(v)
    }
    return out
}

No assertions. No default case. No runtime panic waiting for the day I pass the wrong slice. The compiler knows the types, checks them at compile time, and the whole class of failure I used to carry around just evaporates. I have spent a fair bit of the last year going back through old code and replacing empty interfaces with type parameters, and almost every time the type switch I deleted was hiding an assumption I had never written down.

When it is still the right answer

I do not want to swing too far. The empty interface, now spelled any, is still correct when you genuinely mean "any type and I will not assume otherwise": fmt.Println, the inside of a serialisation library, a cache that stores opaque values it never inspects. The honest test is whether you are going to type-assert it back out. If you are, you probably wanted a type parameter and you were avoiding admitting it. If you genuinely never look inside, any is fine and generics would be cargo cult.

The regret was never the empty interface itself. It was using it as a way to avoid deciding what types my code actually handled, then paying for that postponed decision at runtime, months later, in production. The compiler was offering to hold that thought for me the whole time. I just had to let it.