Static vs Runtime Types: Why Go Won’t Let You Range Over []interface{}

Coping with Go's Type Assertions drama in static vs runtime scenarios. Read along so you don't have to waste an hour of your life.

Picture this scenario. It’s late. You wrote some code. But, the Golang compiler is shouting at you for using a type that it can iterate over. You have verified the type multiple times, but it still won’t compile the code for you. Why is that? Why can’t you iterate over a slice even if it is an interface{}

Here’s a code example to demonstrate this problem. You’re working with JSON data in Go, and you encounter something that seems contradictory.

// After JSON unmarshaling some unstructured data
fmt.Printf("Type: %T\n", result["users"])      // Output: []interface {}

// Let's iterate over this array of interfaces
for i, user := range result["users"] { ... }   // Error: cannot range over interface{}
Go

Wait, what? The type is clearly []interface{} – a slice – so why can’t we iterate over it?
The answer reveals a fundamental aspect of Go’s type system.

Why Go Uses interface{} for JSON

Your confusion makes perfect sense – but it reveals something fundamental about how Go handles unknown data. When Go unmarshals JSON, it faces a problem: JSON is dynamically typed, but Go is statically typed.

// JSON can contain anything:
{
  "name": "Alice",         // string
  "age": 30,               // number
  "active": true,          // boolean
  "scores": [95, 87, 92]   // array
}
Go

Go’s JSON package doesn’t know what types these values will be, so it uses the most flexible container possible: interface{}. This is why result["users"] has the static type interface{} even though it contains a []interface{} at runtime.

The key insight: Go’s JSON unmarshaling prioritizes flexibility over type safety when you use map[string]interface{}.

Root of the Problem: Static vs Runtime Types in Go

The key to understanding this lies in recognizing that Go variables have two types:

  1. Static Type: What the compiler knows at compile time
  2. Runtime Type: What’s actually stored in memory at runtime

Let’s see this in action

package main

import (
"encoding/json"
"fmt"
"reflect"
)

func main() {
   jsonData := `{
       "users": [{"name": "Alice", "social": {"twitter": "@alice"}}]
       }`

   var result map[string]interface{}
   json.Unmarshal([]byte(jsonData), &result)

   // What we get from the map
   usersFromMap := result["users"]

   fmt.Printf("Static type (what compiler sees): interface{}\n")
   fmt.Printf("Runtime type (what's actually stored): %T\n", usersFromMap)
   fmt.Printf("Reflect type: %s\n", reflect.TypeOf(usersFromMap))
}
Go
$ go run main.go

Static type (what compiler sees): interface{}
Runtime type (what's actually stored): []interface {} 
Reflect type: []interface {}
Bash

The Interface Philosophy Behind Your Problem

This isn’t just a JSON quirk – it’s core to how Go thinks about interfaces. In Go, interfaces describe behavior, not data structure. The empty interface interface{} describes something with zero required behaviors – meaning it can hold absolutely anything.

var anything interface{}
  anything = "hello"       // Works
  anything = 42            // Works  
  anything = []int{1,2,3}  // Works - but now anything has static type interface{}
Go

When the JSON package puts a slice into an interface{}, the compiler only knows “this could be anything” – not “this is definitely a slice I can range over.”

Why the Go Compiler Says “No” ??!

When you declare: var result map[string]interface{}

The compiler knows that result["users"] has the static type interface{}.
Even though the JSON unmarshaler puts a []interface{} value there at runtime, the compiler only sees interface{} at compile time.

usersFromMap := result["users"] // Static type: interface{}

// This fails because the compiler sees interface{}, not []interface{}

for i, user := range usersFromMap { // ❌ Compile error
    fmt.Println(i, user)
}
Go

Understanding the Compiler’s Perspective

I see you’re trying to range over an `interface{}`. But, `interface{}` could be anything – a string, a number, a struct, or yes, even a slice. I can’t generate code to iterate over ‘anything’, so this is a compile error.

Type assertions are your way of saying to the compiler: “Trust me, I know what this really is.”

// Type assertion: interface{} → []interface{}

users, ok := result["users"].([]interface{})

// Now users has:
// - Static type: []interface{}
// - Runtime type: []interface{}

for i, user := range users {           // ✅ Works perfectly!
    fmt.Printf("User %d: %v\n", i, user)
}
Go

How Go’s Type Choices Affect You

Understanding why Go makes these type choices helps explain your frustration:

// What JSON unmarshaling actually produces:
var result map[string]interface{}
json.Unmarshal(data, &result)

// JSON numbers become float64 (not int!)
age := result["age"]        // Static type: interface{}, Runtime type: float64
count := age.(int)          // ❌ Panic! It's actually float64
count := int(age.(float64)) // ✅ Works

// This is why you need type assertions everywhere with interface{}
Go

The takeaway: map[string]interface{} forces you into the world of runtime type checking.

Complete Type Assertion Chain in JSON Processing

When working with nested JSON structures, you often need multiple type assertions:

func processUsers(result map[string]interface{}) {
	// 1st assertion: interface{} → []interface{}
	users, ok := result["users"].([]interface{})
	if !ok {
		fmt.Println("Users field is not an array")
		return
	}

	for i, userInterface := range users {
		// 2nd assertion: interface{} → map[string]interface{}
		user, ok := userInterface.(map[string]interface{})
		if !ok {
			fmt.Printf("User %d is not a valid object\n", i)
			continue
		}

		fmt.Printf("Name: %v\n", user["name"])

		// 3rd assertion: interface{} → map[string]interface{}
		if social, ok := user["social"].(map[string]interface{}); ok {
			fmt.Printf("Twitter: %v\n", social["twitter"])
		}
	}
}
Go

Why Each Assertion is Necessary

1. 1st Assertion: Convert interface{} to []interface{} . So we can use range

2. 2nd Assertion: Convert each array element from interface{} to map[string]interface{} . So we can use bracket notation like user["name"]

3. 3rd Assertion: Convert nested objects from interface{} to map[string]interface{} for the same reason

When NOT to Fight the Type System

Your type assertion approach is correct for truly dynamic JSON, but recognizing when you don’t need it prevents this entire problem:

// If you know your JSON structure, skip interface{} entirely:
type Response struct {
    Users []User `json:"users"`
}

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`  // JSON numbers become int, not float64!
}

var response Response
json.Unmarshal(data, &response)

// No type assertions needed - the compiler knows everything!
for _, user := range response.Users {
    fmt.Printf("User: %s\n", user.Name)
}
Go

💡 Most of the time, you can avoid this static/runtime type mismatch entirely by being more specific about your expected structure.

Alternative Approaches

1. Use Structs for Known Schemas

If you know your JSON structure ahead of time, define structs:

type User struct {
	Name   string `json:"name"`
	Age    int    `json:"age"`
	Social struct {
		Twitter  string `json:"twitter"`
		Facebook string `json:"facebook"`
	} `json:"social"`
}

type Response struct {
	Users []User `json:"users"`
}

// Assuming jsonData is a []byte containing your JSON data
var response Response
json.Unmarshal(jsonData, &response)

// No type assertions needed!
for _, user := range response.Users {
	fmt.Printf("Name: %s\n", user.Name)
	fmt.Printf("Twitter: %s\n", user.Social.Twitter)
}
Go

2. Use Libraries for Dynamic JSON

For dynamic JSON structures, consider libraries like gjson

import "github.com/tidwall/gjson"

// Assuming jsonString is a string containing your JSON data
result := gjson.Get(jsonString, "users")

result.ForEach(func(key, value gjson.Result) bool {
	fmt.Printf("Name: %s\n", value.Get("name").String())
	return true // continue iterating
})
Go

The Real-World Decision Tree

When you encounter JSON in Go, ask yourself:

  1. Do I know the structure? → Use typed structs (no type assertions needed)
  2. Do I know some fields? → Mix structs with json.RawMessage
  3. Is it truly unknown? → Use map[string]interface{} + type assertions

Your original example falls into category 3 – truly dynamic JSON where type assertions are the right solution.

Key Takeaways: Watch out for those Type Assertions

1. Static vs Runtime Types: Go variables have both compile-time and runtime types. How compile-time types actually helps in Go. More compile-time shenanigans from go.

2. Compiler Limitations: The compiler only uses static types to determine valid operations. A discussion.

3. Type Assertions: Change the static type to match the runtime type. When it fails, use https://pkg.go.dev/reflect to find the type.

4. Safety First: Always use the “comma ok” idiom to handle assertion failures.

5. Consider Alternatives: Structs for known schemas, specialized libraries for complex dynamic JSON.

TLDR: The Mental Model That Clicks

The breakthrough understanding is this: Go has two different “lenses” for looking at your data:

  • Compile-time lens (static type): “I see an interface{} – this could be anything”
  • Runtime lens (actual data): “This is actually a []interface{} slice”

Type assertions are your way of updating the compiler’s lens to match reality:

// Before type assertion:
usersInterface := result["users"]       // Compiler sees: interface{}
for range usersInterface { ... }        // ❌ "I don't know how to range over 'anything'"

// After type assertion:  
users := usersInterface.([]interface{}) // Compiler now sees: []interface{}
for range users { ... }                 // ✅ "Ah yes, I can range over a slice"
Go

The data never changes – only what the compiler allows you to do with it.

This is why fmt.Printf("%T") shows []interface{} (runtime truth) while the compiler complains about interface{} (compile-time knowledge). Your original confusion was actually perfect intuition – the types really don’t match!

Footnote:

This explanation emerged from a real debugging session where the apparent contradiction between seeing `[]interface{}` as the type but being unable to iterate over it caused significant confusion. The key insight is that `%T` shows the runtime type, while the compiler operates on static types.*

That’s it for now, live in the mix.

A worthy bucket to drop in your thoughts, feedback or rant.

This site uses Akismet to reduce spam. Learn how your comment data is processed.