I once wrote a Go service whose central data structure was a map[string]interface{}, and I am still paying it off. It seemed reasonable at the time. The data was genuinely heterogeneous, the shape varied per request, and interface{} let me wave the whole problem away and get something shipped. Reader, the problem did not go away. It just moved, from compile time, where the compiler would have caught it for free, to runtime, where it caught me, usually in production, usually on a Friday.
The empty interface in Go holds any value. That is its appeal and its whole danger. The compiler will happily let you put anything in and, more to the point, it cannot tell you anything useful about what comes out. So everywhere you want to actually use the value, you need a type assertion, and every type assertion is a small bet that you remembered correctly what you put in there three functions ago.
val := data["count"]
n := val.(int) // panics if it was actually an int64, or a string, or nil
That single line, the comma-less assertion, was the source of more of my pages than any other single pattern in the codebase. The fix for the panic is the comma-ok form, which is genuinely correct and which I learned to use religiously:
n, ok := data["count"].(int)
if !ok {
return fmt.Errorf("count was %T, expected int", data["count"])
}
But notice what that is. It is me, by hand, doing the type checking the compiler would have done for nothing if I had given it a struct to work with. Every comma-ok block is a little gravestone for a guarantee I threw away. Multiply it across a codebase and you have rebuilt a worse, slower, runtime-only version of the type system you already had.
The thing that actually hurt was not the panics. It was that the types were invisible. A new colleague reading the code could not tell what data contained without running it, because the answer lived in the runtime, not in the source. The signature said map[string]interface{}, which is the Go way of writing "I am not going to tell you".
I do still reach for interface{} (or any, when that finally lands, the proposal is doing the rounds). It is the right tool at genuine boundaries: decoding arbitrary JSON, a logging call that takes whatever you hand it, a generic container before we have generics. The rule I follow now is to convert at the boundary and never let it leak inward. Take the formless thing, assert it into a real struct once, with proper error handling, and from that point on the rest of the program works with a type the compiler understands. The empty interface is a fine front door. It is a terrible house to live in.