Day 41 — encoding/json
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, number → float64 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):
- Ship production code on
encoding/json(v1).
- Read release notes when v2 lands in your toolchain.
- Do not churn working services onto experimental APIs for homework.
- When you evaluate v2 later: re-run tests, check
omitempty/nilslice differences, and benchmark hot paths.
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/day41Implement config package above. CLI:
go run ./cmd/loadconfig -config config.jsonPrint validated fields. Exit non-zero on error.
Lab 2 — Strictness & errors
Tests must cover:
- Unknown field rejected (
DisallowUnknownFields)
- Missing
service
- Bad duration string (
"banana")
- Empty file / invalid JSON syntax
- Round-trip:
MarshalIndent→Unmarshalequality 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
DisallowUnknownFieldsproven by test
- Custom
MarshalJSON/UnmarshalJSONworks
- Can explain float64 default for
anynumbers
- 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.