Custom & Streaming JSON
You've marshaled structs with tags, decoded into any for unknown shapes,
and modeled nesting, arrays, and optional fields. This lesson covers the two
things left: making a type control its own wire format, and reading JSON
as a stream instead of one big blob. It assumes the Readers & Writers
chapter's io.Reader/io.Writer — if Read(p []byte) (int, error) doesn't
ring a bell, revisit that first.
The Marshaler and Unmarshaler interfaces
encoding/json checks every value it marshals or unmarshals for two
interfaces:
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}
If a type implements Marshaler, json.Marshal calls its MarshalJSON
instead of using struct-tag reflection — same for Unmarshal and
UnmarshalJSON. This is how you make a type serialize as something other
than its natural shape: a time.Time-style formatted string, an enum as a
name instead of a number, or — as in the exercise — a temperature that
travels as "21.5°C" instead of a bare float.
Value receiver to marshal, pointer receiver to unmarshal
This split isn't arbitrary. Marshaling only reads the value, so a value
receiver works and lets both Temperature and *Temperature marshal
correctly. Unmarshaling has to mutate the value in place — *t = Temperature(v) — so it needs a pointer receiver, exactly like every other
place in Go where a method changes its receiver. Get the receivers backward
and either the method set won't satisfy the interface where you need it, or
your unmarshal silently no-ops on a copy.
The "must emit valid JSON" rule
MarshalJSON doesn't return a Go string — it returns raw JSON bytes. That
distinction bites people constantly:
// Wrong: not valid JSON. json.Marshal on this value will fail downstream,
// or worse, corrupt any document it's embedded in.
func (t Temperature) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf("%g°C", float64(t))), nil
}
20°C unquoted isn't a JSON string, a number, or anything else valid — it's
malformed. The fix is to build the text you want, then let json.Marshal
turn that Go string into a properly quoted, escaped JSON string:
func (t Temperature) MarshalJSON() ([]byte, error) {
return json.Marshal(fmt.Sprintf("%g°C", float64(t)))
}
json.Marshal(someString) always produces valid JSON — quotes, escaping,
everything — so delegating to it is both correct and less code than
hand-rolling the quoting yourself.
Streaming with Encoder and Decoder
json.Unmarshal needs the whole document in memory as a []byte before it
can start. For large payloads, or input that never technically "ends" — a
log tailer, a long-lived connection — that's the wrong shape. json.Encoder
and json.Decoder work directly against an io.Writer/io.Reader, so you
never buffer more than one value at a time:
dec := json.NewDecoder(r) // r is any io.Reader
for dec.More() {
var reading Reading
if err := dec.Decode(&reading); err != nil {
return err
}
process(reading)
}
dec.More() reports whether there's another JSON value left in the stream,
so the loop naturally stops at the end without you tracking byte counts.
Each Decode call reads exactly one value and advances the stream past it —
that's the whole trick, and it's why Decoder handles endless input that
ReadAll + Unmarshal never could: it doesn't need an end to allocate for.
json.NewEncoder(w) is the mirror image for writing a stream of values one
Encode call at a time.
One footnote on the ° in "21.5°C": Go's JSON encoder only HTML-escapes
<, >, and & by default (a safety measure for JSON embedded in
<script> tags), so non-ASCII characters like ° pass through untouched.
Practice
1. A Custom Temperature
Intermediateencoding/jsoncalls YOUR methods when a type defines them. GiveTemperaturea custom wire format: a JSON string like"21.5°C".MarshalJSON() ([]byte, error)— value receiver. Output must be a valid JSON string, quotes included:"20°C","-3.5°C". Format the number with%g(no trailing zeros).UnmarshalJSON(b []byte) error— pointer receiver. Accept exactly that format; anything else ("18.5", a bare number,"warm°C") is an error.
The classic bug: returning
[]byte("20°C")— unquoted — from MarshalJSON. That's not valid JSON, and Marshal fails. Build a string and letjson.Marshalquote it for you.The hidden tests also round-trip a struct containing a
Temperatureand decode a stream of readings withjson.Decoder— no extra work if your two methods are right.package main import ( "encoding/json" "fmt" ) // Temperature is a Celsius reading that travels through JSON as a string // like "21.5°C" instead of a bare number. type Temperature float64 // MarshalJSON encodes the temperature as a quoted string: "21.5°C". func (t Temperature) MarshalJSON() ([]byte, error) { // TODO: emit a quoted string like "20°C" — format with %g, then let // json.Marshal quote it for you. return json.Marshal(float64(t)) } // UnmarshalJSON accepts exactly what MarshalJSON produces. func (t *Temperature) UnmarshalJSON(b []byte) error { // TODO: unmarshal b into a string first, trim the °C suffix, ParseFloat // the rest; any deviation is an error. return nil } func main() { out, err := json.Marshal(Temperature(21.5)) if err != nil { fmt.Println("error:", err) return } fmt.Println(string(out)) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.