skip to content

Types on the Wire

Which Go types survive the round trip: a decode into any hands back map[string]any and float64, and Marshal refuses chan, func and NaN while Unmarshal refuses a non-pointer target.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

What concrete Go types does json.Unmarshal produce when the target is an `any`?

level: juniorimportance: must knowfreq 72%

answer

  1. six shapes, and no more
  2. objects and arrays become open containers
  3. every number gets one and the same type
  4. null keeps no type at all
  5. float64, even for an integer id

basics

~10 s

Six concrete types, and no others: map[string]any for a JSON object, []any for an array, string for a string, float64 for every number, bool for true and false, and an untyped nil for null.

solid answer

~50 s

When the destination of `json.Unmarshal` is an interface value with no methods, the decoder has no Go type to guide it, so it uses a fixed table: a JSON object becomes `map[string]any`, an array becomes `[]any`, a string becomes `string`, **every** number becomes `float64` regardless of whether it looked like an integer, `true`/`false` become `bool`, and `null` leaves the interface holding nothing at all — a nil interface. Nesting composes, so a JSON object of arrays of objects decodes into `map[string]any` holding `[]any` holding more `map[string]any`. Because the result is a map, object key order is gone. The practical consequences are that you must reach values through type assertions, that `x.(int)` never succeeds on a decoded number, and that a JSON `null` and a missing key look almost identical unless you use the comma-ok form on the map lookup.

code

go · 7 lines
go
var v any
if err := json.Unmarshal([]byte(`{"name":"ada","age":36,"tags":["x"],"boss":null}`), &v); err != nil {
	return err
}
m := v.(map[string]any)
fmt.Printf("%T %T %T %T\n", m["name"], m["age"], m["tags"], m["boss"])
// string float64 []interface {} <nil>

go deeper

for a junior

Be ready to recite the six results — object, array, string, number, boolean, null — and to say out loud that the number case gives float64. Also remember the destination must be passed as a pointer.

for a middle

Explain why the decoder has no better choice than float64 when no Go type steers it, and show the comma-ok assertion and the type switch you would use to walk such a document safely.

for a senior

Show judgment about when a schemaless decode is appropriate at all, and describe how you would catch a payload whose shape drifted before a type assertion panics inside business logic.

for a principal

Frame it as a boundary policy: which services in the estate are allowed to carry untyped documents through, and what you require of a team that decodes into an untyped map instead of declaring the contract.

## The question behind the question Go's `encoding/json` normally decodes into a type you supply: you declare a struct, call `json.Unmarshal(data, &cfg)`, and the decoder uses the struct's fields to decide what each JSON value should become. But sometimes you do not know the shape ahead of time — a webhook whose body varies, a payload you are exploring at a terminal, a passthrough that only touches one field. In that case you decode into `any` (the alias for `interface{}`), and the decoder falls back on a fixed mapping. ## The mapping The decoder produces exactly six things: | JSON | Go | |---|---| | object | `map[string]any` | | array | `[]any` | | string | `string` | | number | `float64` | | `true` / `false` | `bool` | | `null` | nil interface (no type, no value) | That is the whole table. There is no `int`, no `int64`, no `time.Time`, no `map[string]string` — those require a typed destination for the decoder to aim at. ## Why every number is a float64 JSON has one numeric type. The grammar does not distinguish `7` from `7.0` from `7e0`; they are the same JSON value. With no Go type to steer it, the decoder cannot know whether you meant a counter, a price, or an id, so it picks the type that can represent the widest range of JSON numbers: `float64`. This is the single most surprising part of the table for newcomers, and it has a real cost — a `float64` carries 53 bits of mantissa, so an id above roughly 9 quadrillion silently rounds. If a decoded number must survive exactly, either decode into a typed field (an `int64` field is parsed as an integer directly, with no floating-point detour) or switch the decoder into a mode that keeps the original literal. ## Why null is not a zero value A JSON `null` stored into an `any` does not become `0`, `""`, or an empty map — it leaves the interface with no dynamic type at all, so `v == nil` is true and `fmt.Printf("%T", v)` prints `<nil>`. That means a key present with a `null` value and a key that never appeared both produce a nil-ish result at the use site. You can still tell them apart, but only at the map level, with the comma-ok form: `raw, present := m["boss"]` gives `present == true` and `raw == nil` for an explicit `null`, and `present == false` when the key was absent. ## Getting values back out Everything in the tree is an `any`, so every read is a type assertion, and every assertion must use the comma-ok form unless you are certain of the shape: ```go m, ok := v.(map[string]any) if !ok { /* payload was not an object */ } name, ok := m["name"].(string) ``` The single-result form `m["name"].(string)` panics when the payload disagrees, and payloads disagree in production far more often than in the sample you developed against. A `switch t := v.(type)` with cases for the six types above is the exhaustive way to walk an unknown document. ## What the decoder still checks Decoding into `any` is not decoding without validation. The input must be syntactically valid JSON; a malformed body still returns a `*json.SyntaxError`. And the destination must be a non-nil pointer — `json.Unmarshal(data, v)` where `v` is a plain `any` value returns a `*json.InvalidUnmarshalError`, because the argument is copied into the parameter and the decoder has nowhere to write. Idiomatic use is `var v any; err := json.Unmarshal(data, &v)`. ## Order is not preserved Because a JSON object becomes a Go map, the order in which keys appeared in the document is lost, and iterating that map yields keys in a deliberately randomised order. Re-encoding a `map[string]any` writes keys sorted, not in the original order. If key order matters to a consumer — it rarely should, since JSON objects are unordered by definition — a `map[string]any` round trip is the wrong tool. ## When to use it Decoding into `any` earns its place for genuinely schemaless data, for a quick exploration of an unfamiliar payload, and for code that must forward a document it does not understand. For anything you actually read fields out of, a struct is shorter, faster, self-documenting, and turns a runtime panic into a decode error you can log.

  • Why must the second argument to json.Unmarshal be a pointer, and what happens if it is not?
    The argument is copied into an `any` parameter, so without a pointer the decoder would only fill a copy that nobody can see. Rather than doing that silently it returns a `*json.InvalidUnmarshalError`, whose message reads like `json: Unmarshal(non-pointer main.Config)`. Passing a nil pointer gives the same error type. The idiomatic call is `json.Unmarshal(data, &v)`.
  • How do you tell an explicit JSON null apart from a key that was never sent, once the payload is a map[string]any?
    Use the comma-ok form on the map lookup rather than looking at the value. `raw, present := m["boss"]` gives `present == true` with `raw == nil` for an explicit `null`, and `present == false` when the key was absent. Checking only `m["boss"] == nil` collapses the two cases, because a missing key yields the zero value of the map's element type, which for `any` is nil.
  • Does a map[string]any keep the order the keys appeared in the JSON document?
    No. A Go map has no order, and ranging over one yields keys in a deliberately randomised order. Re-encoding a `map[string]any` writes the keys sorted, so a decode-then-encode round trip generally reorders the document. JSON objects are unordered by specification, so a consumer that depends on key order is already relying on something the format does not promise.

It is like reading a form filled in with no field labels: you can see text, digits and blanks, but nothing tells you which digits were meant as a whole count and which as a measurement, so everything numeric is filed under one heading.

saying these in an interview costs you the question

  • Says a whole JSON number decodes into an int
  • Expects a JSON object to become map[string]string
  • Thinks JSON null decodes to an empty string or zero
  • Uses single-result type assertions on decoded values
  • Claims a map[string]any preserves key order
  • Passes the destination by value instead of by pointer
open as a page

Why does JSON decoding into a Go `any` mangle a 19-digit id, and how do you keep it exact?

level: middleimportance: must knowfreq 58%

basics

~20 s

Decoding into an any makes every JSON number a float64, whose 53-bit mantissa cannot hold a 19-digit id, so it rounds silently. Use an int64 field, or a json.Decoder with UseNumber to get a json.Number.

open as a page

Which Go types does json.Marshal encode as something other than their obvious JSON counterpart?

level: middleimportance: should knowfreq 48%

basics

~10 s

A []byte becomes a base64 string, not an array of numbers. A nil slice or map becomes null while an empty non-nil one becomes [] or {}. Integer map keys become quoted object keys.

open as a page

Your service decodes every payload into map[string]any and type-asserts at use sites. Why does that panic in production?

level: seniorimportance: should knowfreq 44%

basics

~10 s

Decoding into map[string]any succeeds for any valid JSON, so nothing checks shape, and each single-result type assertion is an unchecked bet. Drift panics deep in business logic instead of failing at the decode.

open as a page

Which Go values make json.Marshal return an error instead of producing JSON?

level: middleimportance: nice to knowfreq 33%

basics

~20 s

Channels, functions and complex numbers have no JSON form and give an UnsupportedTypeError. The float values NaN, positive and negative infinity give an UnsupportedValueError. So does a pointer cycle. Nothing is written: Marshal returns nil bytes plus the error.

open as a page