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

interface{} is not a type, it's a promise to deal with it later

How reaching for interface{} to make a Go API "flexible" pushed every type error from compile time to runtime, and the type switch I should have written instead.

A code editor showing Go source on screen

I wrote a function that took interface{} because I wanted it to be flexible. Six months later that flexibility had cost me three panics in production and a lot of grovelling through stack traces, and I'd like to talk you out of the same mistake.

The empty interface in Go is satisfied by every type, which sounds like power and is mostly an absence of one. The moment a value goes into an interface{}, the compiler stops being able to help you. It no longer knows whether you've got an int, a string, a *Thing, or a nil that's going to bite. All it knows is that you've got something, and that you have personally taken responsibility for figuring out what.

Here's the shape of the regret. I had a config loader, and I wanted callers to be able to register handlers for arbitrary values:

func (c *Config) Set(key string, value interface{}) {
    c.values[key] = value
}

func (c *Config) GetInt(key string) int {
    return c.values[key].(int) // confident. wrong.
}

That type assertion, .(int), is a bet. When the stored value really is an int, you win. When some caller stored a string because the config came from a file and everything from a file is a string, you get a panic at runtime, in production, at the exact moment you can least afford one. The compiler watched me write that line and said nothing, because as far as it was concerned I knew what I was doing.

A close-up of programming code on a monitor

what I should have done

The first and best fix is usually to not use interface{} at all. If the values are really one of a handful of known types, model that. Concrete types, or a small set of methods behind a named interface that actually describes behaviour, give the compiler something to check. "Accept anything" is rarely a real requirement; it's usually "I haven't decided yet", wearing a disguise.

Where you genuinely can't avoid it, at a boundary where the type really is unknown until runtime such as decoding arbitrary JSON, the rule is: assert with the two-value form, never the one-value form, and handle the failure like you mean it.

func (c *Config) GetInt(key string) (int, error) {
    v, ok := c.values[key]
    if !ok {
        return 0, fmt.Errorf("config: no value for %q", key)
    }
    n, ok := v.(int)
    if !ok {
        return 0, fmt.Errorf("config: %q is %T, not int", key, v)
    }
    return n, nil
}

The single-return assertion v.(int) panics on mismatch. The comma-ok form n, ok := v.(int) hands you a boolean instead, and now a wrong type is an error you return up the stack rather than a crash you discover from a pager. When you've several possible types, a type switch reads better than a ladder of assertions:

switch v := value.(type) {
case int:
    return v
case string:
    n, err := strconv.Atoi(v)
    ...
default:
    return 0, fmt.Errorf("unsupported type %T", v)
}

The thing I'd tell my earlier self is that interface{} doesn't make code flexible. It makes it deferred. Every type question you'd normally have answered at compile time is still there, every one of them, you've just moved them to runtime where they cost more and arrive less politely. Reach for it at the genuine edges of the program, decoders and generic containers, and the moment a value crosses back into your own code, pin it down to a real type as fast as you can. The compiler is the cheapest tester you'll ever have. Don't blindfold it for the sake of a function signature that looks accommodating.