Skip to content

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

📝 Notes

  • A struct tag is a backtick string after the field: `json:"name,omitempty"`. The encoder reads the json key. Format is name,option1,option2 → [[struct-tags]].
  • Tag options:
  • rename: `json:"id"` emits key id.
  • ,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.Marshaler is one method: MarshalJSON() ([]byte, error) — it must return a complete, valid JSON value. When a type implements it, json calls it instead of reflecting → your escape hatch for custom shapes → [[json-marshaler]].
  • json.Unmarshaler is UnmarshalJSON([]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 then json.Marshal a string/struct to get safe quoting — don't hand-roll escapes.
  • Recursion trap: calling json.Marshal(t) on the same type inside its own MarshalJSON infinite-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 UnmarshalJSON with a value receiver → decode silently ignored it and left the field zero (the method set of *T is what json looks for).
  • Called json.Marshal(t) inside t.MarshalJSON → stack overflow from infinite recursion. Fixed with an alias type.
  • Expected ,omitempty to drop an all-zero nested struct — it doesn't; only the listed "empty" kinds qualify.

❓ Open Questions

  • For optional fields, when is *T with omitempty better than a sentinel, and how does that interact with ,string?

🧠 Active Recall (answer without looking)

  1. Q: Why must UnmarshalJSON use 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.

  2. Q: What does ,omitempty consider "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.RawMessage for half-custom payloads.
  • Interaction of ,string with 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 time package — 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)