Dynamic JSON
Lesson 1 covered structs: nested types, slices of objects, pointers for optional fields. That works whenever you know the shape ahead of time. This lesson is for when you don't — a webhook payload from a service you don't control, a plugin config with arbitrary keys, a "just show me what's in here" debugging tool.
Decoding into map[string]any
Give Unmarshal a map[string]any instead of a struct and it stores
whatever it finds, key by key:
var doc map[string]any
json.Unmarshal([]byte(`{"name":"Ada","stars":100,"active":true}`), &doc)
Each JSON kind lands as a specific Go type inside that any:
| JSON kind | Go type |
|---|---|
| string | string |
| number | float64 |
| bool | bool |
| null | nil |
| array | []any |
| object | map[string]any |
Nesting isn't special-cased — an object inside your object just becomes
another map[string]any value, an array becomes []any, and so on,
recursively, as deep as the document goes.
The float64 surprise
The one everyone trips over: JSON has no separate "integer" type, so
encoding/json decodes every JSON number — 7, 7.5, -1 — as a Go
float64. Even values that look like clean integers in the source text:
var doc map[string]any
json.Unmarshal([]byte(`{"count": 7}`), &doc)
n := doc["count"].(float64) // not int — always float64
If you need an int, convert explicitly (int(n)) after the type
assertion, and decide what to do about values that aren't whole numbers.
There's no way to ask encoding/json for int directly through any —
the type is fixed at float64 regardless of what the source looked like.
Type switches to recover concrete types
A single assertion like doc["count"].(float64) panics if the key holds
something else. When you don't know — or want to branch on — the kind, use
a type switch instead:
for key, v := range doc {
switch val := v.(type) {
case string:
fmt.Println(key, "is a string:", val)
case float64:
fmt.Println(key, "is a number:", val)
case bool:
fmt.Println(key, "is a bool:", val)
case nil:
fmt.Println(key, "is null")
case []any:
fmt.Println(key, "is an array of", len(val), "items")
case map[string]any:
fmt.Println(key, "is a nested object")
}
}
Each case gives you val already narrowed to that concrete type — no
further assertion needed inside the branch.
json.RawMessage: decode later
Sometimes you know part of a document's shape but not all of it — an envelope with a fixed header and a payload that varies by message type:
type Envelope struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
Unmarshal reads Type normally but leaves Payload as raw, unparsed
bytes. Once you've inspected Type, decode Payload a second time into
whatever struct matches it:
var env Envelope
json.Unmarshal(data, &env)
switch env.Type {
case "order":
var o Order
json.Unmarshal(env.Payload, &o)
case "refund":
var r Refund
json.Unmarshal(env.Payload, &r)
}
This beats decoding straight into map[string]any when you do know the
final shape per case — you get real structs, not a pile of assertions.
Validation aids worth knowing
json.Valid(data) checks well-formedness without allocating a decode
target — handy for a fast pre-check before committing to a full unmarshal.
json.NewDecoder(r).DisallowUnknownFields() makes decoding into a struct
fail if the input has keys your struct doesn't declare — useful for strict
APIs where an unexpected field should be an error, not silently dropped.
When to reach for dynamic
Structs should be your default — they're type-safe, self-documenting, and
the compiler catches typos in field names. map[string]any is the escape
hatch for the specific cases where the shape genuinely isn't known until
runtime. Reach for it, json.RawMessage, or a hybrid of both only when a
fixed struct truly can't express what you're decoding — which, in most
codebases, is rarer than it first seems.
Practice
1. Count the Kinds
IntermediateSometimes you don't know a document's shape ahead of time. Decode it into
map[string]anyand inspect what you got.Implement
CountTypes(data []byte) (map[string]int, error):dataholds a JSON object. Count its top-level values by JSON kind, using exactly these keys:"string","number","bool","null","array","object".- Only kinds that actually occur appear in the result (an empty object yields an empty, non-nil map).
- If
dataisn't valid JSON — or isn't an object at all — returnniland an error.
Gotcha to savor: unmarshal
7into ananyand you getfloat64(7), notint. Every JSON number does this.package main import ( "fmt" ) // CountTypes reports how many top-level values of each JSON kind appear in // the object encoded in data. Kinds: "string", "number", "bool", "null", // "array", "object". Only kinds that actually occur appear in the result. func CountTypes(data []byte) (map[string]int, error) { // TODO: unmarshal into map[string]any, then switch on each value's // dynamic type — remember every JSON number arrives as float64 return make(map[string]int), nil } func main() { counts, err := CountTypes([]byte(`{"name":"learngo","stars":100,"active":true}`)) if err != nil { fmt.Println("error:", err) return } fmt.Println(counts) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.