Day 037 — Struct Tags & Custom (Un)Marshaler¶
Month 2 · Week 2 · ⬅ Day 036 · Day 038 ➡ · Journal index
🎯 Learning Objective¶
Control JSON output precisely with struct tags (omitempty, -, rename), and take full control of a type's encoding by implementing json.Marshaler / json.Unmarshaler.
📚 Topics¶
- Struct tags: rename,
omitempty,-,,string json.Marshaler(MarshalJSON) ·json.Unmarshaler(UnmarshalJSON)- Value vs pointer receivers for the two interfaces
- The
omitempty"empty" definition and its limits
📖 Reading / Sources¶
-
encoding/json— struct tags & Marshaler -
json.Marshaler·json.Unmarshaler - Go wiki — Well-known struct tags
📝 Notes¶
- A struct tag is a backtick string after the field:
`json:"name,omitempty"`. The encoder reads thejsonkey. Format isname,option1,option2→ [[struct-tags]]. - Tag options:
- rename:
`json:"id"`emits keyid. ,omitempty: drop the field when it holds its empty value (false, 0, "", nil, empty slice/map/array). Note: it does not treat an all-zero struct as empty — a common surprise.-:`json:"-"`never serialize (secrets/internal). Use`json:"-,"`if you really want a field literally named-.,string: encode a numeric/bool/string field as a quoted JSON string (interop with systems that send"42").json.Marshaleris one method:MarshalJSON() ([]byte, error)— it must return a complete, valid JSON value. When a type implements it,jsoncalls it instead of reflecting → your escape hatch for custom shapes → [[json-marshaler]].json.UnmarshalerisUnmarshalJSON([]byte) error, receiving the raw JSON for that field. It must have a pointer receiver so it can mutate the value; otherwise it's never used during decode → [[unmarshaler-pointer-receiver]].- Inside
MarshalJSON, build the text thenjson.Marshala string/struct to get safe quoting — don't hand-roll escapes. - Recursion trap: calling
json.Marshal(t)on the same type inside its ownMarshalJSONinfinite-loops. Convert to a different alias type (type alias T) that lacks the method, then marshal that. - Custom (un)marshaling composes: a struct field whose type implements the interfaces is encoded/decoded through them automatically, even when nested.
💻 Code Examples¶
type Temperature float64
// Value receiver: serialize as a quoted string like "21.5C".
func (t Temperature) MarshalJSON() ([]byte, error) {
return json.Marshal(fmt.Sprintf("%.1fC", float64(t)))
}
// Pointer receiver (required) so decode can mutate the value.
func (t *Temperature) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
f, err := strconv.ParseFloat(strings.TrimSuffix(s, "C"), 64)
if err != nil {
return fmt.Errorf("temperature %q: %w", s, err)
}
*t = Temperature(f)
return nil
}
Full code:
examples/month-02/json-tags/main.go· Run:go run ./examples/month-02/json-tags
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
Celsius custom (un)marshaler + omitempty field |
✅ | exercises/month-02/week-2/tempjson |
🐛 Mistakes Made¶
- Wrote
UnmarshalJSONwith a value receiver → decode silently ignored it and left the field zero (the method set of*Tis whatjsonlooks for). - Called
json.Marshal(t)insidet.MarshalJSON→ stack overflow from infinite recursion. Fixed with an alias type. - Expected
,omitemptyto drop an all-zero nested struct — it doesn't; only the listed "empty" kinds qualify.
❓ Open Questions¶
- For optional fields, when is
*Twithomitemptybetter than a sentinel, and how does that interact with,string?
🧠 Active Recall (answer without looking)¶
-
Q: Why must
UnmarshalJSONuse a pointer receiver?
A
Decoding has to mutate the destination. `json` looks for the `Unmarshaler` method on the *addressable* value it's filling; a value-receiver method isn't in the pointer-target's required method set for mutation, so it's never invoked and the field stays zero. -
Q: What does
,omitemptyconsider "empty", and what's the classic gotcha?
A
`false`, `0`, `""`, `nil`, and empty slice/map/array/len-0. The gotcha: an all-zero **struct** value is *not* empty, so it still serializes. Use a pointer field if you need "absent vs zero".
🪶 Feynman Reflection¶
Struct tags are little notes pinned to each field telling the JSON translator what to call it and when to leave it out. When the default translation isn't enough, you hand the type its own translator (MarshalJSON/UnmarshalJSON) — and decode's translator must work on the original (a pointer), or its edits are thrown away.
🕳️ Knowledge Gaps¶
json.RawMessagefor half-custom payloads.- Interaction of
,stringwith custom marshalers.
✅ Summary¶
I can rename/omit/hide fields with tags and fully customize a type's JSON with Marshaler/Unmarshaler, remembering the pointer-receiver and self-recursion traps.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 038: the
timepackage — durations, the reference-time layout, timers and tickers.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦⬜⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: feat(exercises): struct tags and custom json (un)marshaler (day 037)