Day 41 — encoding/json

Updated

July 30, 2026

Day 41 — encoding/json

Stage V · ~3h
Goal: Load and validate JSON configuration with struct tags, custom types, and streaming APIs—using today’s production stdlib encoding/json, while knowing where json/v2 is headed.

Why this day exists

JSON is the default interchange for HTTP APIs, configs, and cloud control planes. Go’s encoding/json is powerful but full of sharp edges: exported fields only, surprising nil vs empty, numberfloat64 defaults, and timezone formats.

Master the v1 package thoroughly; treat v2 as direction awareness, not a rewrite mandate for this volume.


Theory 1 — Marshal / Unmarshal basics

type Config struct {
    Host string `json:"host"`
    Port int    `json:"port"`
}

b, err := json.Marshal(Config{Host: "localhost", Port: 8080})
// b == []byte(`{"host":"localhost","port":8080}`)

var c Config
err = json.Unmarshal(b, &c)

Tag grammar (common)

Tag Effect
json:"name" Wire name
json:"-" Skip field
json:"name,omitempty" Omit if empty
json:",omitempty" Default name + omitempty
json:"name,string" Encode number/bool as JSON string

Empty for omitempty: false, 0, "", nil pointer/slice/map, empty slice/map, zero struct.

Visibility rule

Only exported fields participate. Unexported fields are silently ignored—a classic “why is my field missing?” bug.


Theory 2 — Streaming Encoder / Decoder

dec := json.NewDecoder(r)
dec.DisallowUnknownFields() // optional strictness

var cfg Config
if err := dec.Decode(&cfg); err != nil {
    return err
}

enc := json.NewEncoder(w)
enc.SetIndent("", "  ")
if err := enc.Encode(cfg); err != nil { // Encode adds trailing newline
    return err
}

Prefer Decoder for large streams and NDJSON:

for {
    var rec Record
    if err := dec.Decode(&rec); err == io.EOF {
        break
    } else if err != nil {
        return err
    }
    // handle rec
}

Theory 3 — Custom marshaling

type DurationMS time.Duration

func (d DurationMS) MarshalJSON() ([]byte, error) {
    return json.Marshal(time.Duration(d).Milliseconds())
}

func (d *DurationMS) UnmarshalJSON(b []byte) error {
    var ms int64
    if err := json.Unmarshal(b, &ms); err != nil {
        return err
    }
    *d = DurationMS(time.Duration(ms) * time.Millisecond)
    return nil
}

Also available: MarshalText / UnmarshalText for types used as map keys or textual forms.

json.RawMessage

Delay parsing of nested blobs:

type Envelope struct {
    Type string          `json:"type"`
    Data json.RawMessage `json:"data"`
}

Theory 4 — Numbers, time, and maps

Pattern Behavior
Decode into interface{} / any Numbers become float64
Large integers Prefer json.Number via Decoder.UseNumber() or typed fields
time.Time RFC3339 by default
map[string]any Flexible but weakly typed—validate yourself
dec := json.NewDecoder(r)
dec.UseNumber()

Theory 5 — json/v2 awareness (only)

Go’s ecosystem is evolving a second JSON implementation (often discussed as encoding/json/v2 / experimental packages). High-level intent:

  • Cleaner performance and behavior defaults
  • More consistent case handling and nil semantics
  • Opt-in migration path rather than silent break

For this volume (Go 1.26 baseline):

  1. Ship production code on encoding/json (v1).
  2. Read release notes when v2 lands in your toolchain.
  3. Do not churn working services onto experimental APIs for homework.
  4. When you evaluate v2 later: re-run tests, check omitempty/nil slice differences, and benchmark hot paths.
Note

Awareness ≠ adoption. Interview answer: “I know v2 is the modernization path; production today is v1 with explicit tests around edge cases.”


Worked example — config loader

package config

import (
    "encoding/json"
    "fmt"
    "io"
    "os"
    "time"
)

type Config struct {
    Service  string        `json:"service"`
    HTTPAddr string        `json:"http_addr"`
    Timeout  time.Duration `json:"timeout"` // custom via wrapper below if needed
    Features FeatureFlags  `json:"features"`
    Upstream []string      `json:"upstream,omitempty"`
}

type FeatureFlags struct {
    BetaUI bool `json:"beta_ui"`
    Debug  bool `json:"debug"`
}

// Duration as string like "5s" in JSON:
type Duration struct{ time.Duration }

func (d Duration) MarshalJSON() ([]byte, error) {
    return json.Marshal(d.String())
}

func (d *Duration) UnmarshalJSON(b []byte) error {
    var s string
    if err := json.Unmarshal(b, &s); err != nil {
        return err
    }
    parsed, err := time.ParseDuration(s)
    if err != nil {
        return err
    }
    d.Duration = parsed
    return nil
}

type FileConfig struct {
    Service  string       `json:"service"`
    HTTPAddr string       `json:"http_addr"`
    Timeout  Duration     `json:"timeout"`
    Features FeatureFlags `json:"features"`
    Upstream []string     `json:"upstream,omitempty"`
}

func Load(r io.Reader) (FileConfig, error) {
    dec := json.NewDecoder(r)
    dec.DisallowUnknownFields()
    var cfg FileConfig
    if err := dec.Decode(&cfg); err != nil {
        return cfg, err
    }
    if err := cfg.Validate(); err != nil {
        return cfg, err
    }
    return cfg, nil
}

func (c FileConfig) Validate() error {
    if c.Service == "" {
        return fmt.Errorf("service is required")
    }
    if c.HTTPAddr == "" {
        return fmt.Errorf("http_addr is required")
    }
    if c.Timeout.Duration <= 0 {
        return fmt.Errorf("timeout must be positive")
    }
    return nil
}

func LoadFile(path string) (FileConfig, error) {
    f, err := os.Open(path)
    if err != nil {
        return FileConfig{}, err
    }
    defer f.Close()
    return Load(f)
}

Example config.json:

{
  "service": "demo",
  "http_addr": ":8080",
  "timeout": "3s",
  "features": { "beta_ui": true, "debug": false },
  "upstream": ["http://127.0.0.1:9000"]
}

Lab 1 — Module + happy path

mkdir -p ~/lab/90daysofx/01-go/day41
cd ~/lab/90daysofx/01-go/day41
go mod init example.com/day41

Implement config package above. CLI:

go run ./cmd/loadconfig -config config.json

Print validated fields. Exit non-zero on error.


Lab 2 — Strictness & errors

Tests must cover:

  1. Unknown field rejected (DisallowUnknownFields)
  2. Missing service
  3. Bad duration string ("banana")
  4. Empty file / invalid JSON syntax
  5. Round-trip: MarshalIndentUnmarshal equality for timeout
func TestUnknownField(t *testing.T) {
    _, err := Load(strings.NewReader(`{"service":"x","http_addr":":1","timeout":"1s","nope":1}`))
    if err == nil {
        t.Fatal("expected unknown field error")
    }
}

Lab 3 — NDJSON metrics stream

Write StreamDecode(r io.Reader, fn func(Rec) error) error that decodes one JSON object per call until EOF. Feed:

{"n":1}
{"n":2}

Assert fn invoked twice. Handle trailing garbage as error.


Lab 4 — Interface decode discipline

Decode {"x": 9007199254740993} into map[string]any and observe float precision issues. Re-decode with UseNumber and show json.Number.Int64(). Write a 5-line note in README: when to use typed structs vs any.


Common gotchas

Gotcha Fix
Unexported fields missing Export + tag
nil slice vs [] Decide API; omitempty omits both empty and nil
json.Marshal of channel/func Error—don’t
Floating large ints Typed ints or UseNumber
Forgetting pointer receiver on UnmarshalJSON Must be *T to mutate
Encode trailing newline Expected; trim in tests if comparing exact bytes
Blind trust of external JSON Validate after decode

Checkpoint

  • Config loads from file with duration strings
  • DisallowUnknownFields proven by test
  • Custom MarshalJSON/UnmarshalJSON works
  • Can explain float64 default for any numbers
  • Can state a one-paragraph json/v2 awareness answer
  • NDJSON stream decoder handles EOF correctly

Commit

git add .
git commit -m "day41: encoding/json config loader + custom types"

Write three personal gotchas before continuing.


Tomorrow

Day 42 — os / os/exec: files, env, permissions, and subprocesses with CommandContext timeouts—without shell injection footguns.