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

interface{} got me out of a corner and then quietly furnished it

Reaching for the empty interface to handle arbitrary JSON felt clever until the type assertions and the runtime panics started arriving.

A screen of Go source code

Go does not have generics yet. We have all made our peace with this in different ways, and one of the more popular ways, the one I keep reaching for and keep regretting, is interface{}. The empty interface. It satisfies everything, which is exactly the problem.

The case was an integration with a third-party API that returned JSON of a shape I could not fully pin down. Some fields were strings, sometimes numbers, occasionally arrays, depending on a state I did not control. Defining a proper struct felt impossible, so I decoded into map[string]interface{} and got on with my life.

It worked. It worked the way borrowing money works: brilliantly, right up until the bill.

data := raw["items"].([]interface{})
for _, item := range data {
    m := item.(map[string]interface{})
    name := m["name"].(string)
    // ...
}

Every one of those assertions is a small bet that the data is shaped the way I assumed. When I am right, nobody notices. When I am wrong, the program panics in production at the exact moment the upstream API decided items should be null instead of an empty array. The compiler had nothing to say about any of it, because to the compiler this is all just interface{}, and interface{} means "trust me".

what I should have done sooner

The fix was not clever, it was disciplined. I sat down and actually catalogued the shapes the API returned, all of them, including the annoying edge cases I had been pretending were rare. Then I wrote proper structs with explicit types, used json.RawMessage for the genuinely polymorphic fields, and put the messy decisions in one place behind a method instead of scattering type assertions through the whole codebase.

The comma-ok form helps too, and I should have been using it from the start:

name, ok := m["name"].(string)
if !ok {
    return fmt.Errorf("expected name to be a string, got %T", m["name"])
}

That turns a panic into an error I can handle, which is the whole difference between a 3am page and a log line.

The empty interface is a genuinely useful escape hatch. The encoding/json package could not work without it. My mistake was treating an escape hatch as a front door. Every interface{} I leave in is a piece of type checking I have promised to do myself, by hand, forever, and I am not a reliable enough person to keep that promise. When generics finally arrive, a lot of this will get nicer. Until then, the discipline is to push the empty interface as close to the boundary as possible and turn it back into real types the moment I can.