Day 16 — io Interfaces, bufio & encoding/json

Updated

July 30, 2025

Day 16 — io Interfaces, bufio & encoding/json

Day 39 — io interfaces

Stage V · ~3h (composition-heavy)
Goal: Internalize Go’s streaming I/O model—minimal interfaces, composition, and correct error/EOF handling—then build a transforming Reader you can plug into io.Copy.

Note

Stage V is stdlib that pays rent. Almost every network, file, and encoding path you touch for the rest of this volume is an io.Reader or io.Writer in disguise.

Why this day exists

fmt.Println and os.ReadFile hide the real model. Production Go streams bytes:

  • Files, sockets, HTTP bodies, compressors, encoders
  • Pipes between processes
  • In-memory buffers that still speak the same interfaces

If you only ever load whole files into []byte, you will write fragile services when payloads grow or when you must chain transforms.


Theory 1 — The minimal contracts

From io (simplified):

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

type Closer interface {
    Close() error
}

type ReadCloser interface {
    Reader
    Closer
}

type WriteCloser interface {
    Writer
    Closer
}

Rules that prevent bugs

  1. Read may return n > 0 and err != nil in the same call. Process the bytes first, then the error.
  2. io.EOF means “no more data,” not failure. Callers that treat EOF as fatal are wrong.
  3. Write must report short writes. If n < len(p) and err == nil, that is a contract violation by the writer; io.ErrShortWrite exists for helpers that detect it.
  4. Interfaces are satisfied implicitly. Your type only needs the methods.

Why small interfaces win

Anything that can fill a buffer is a Reader. You can:

io.Copy(dst, src) // works for files, net.Conn, bytes.Buffer, http.Response.Body, ...

without caring what src or dst really are.


Theory 2 — Copy, Limit, Multi, Tee

io.Copy

// Copy copies from src to dst until EOF or error.
// Returns bytes written and the first error encountered (EOF is not an error).
n, err := io.Copy(dst, src)

Internally it uses a buffer (or WriterTo / ReaderFrom fast paths when available). Prefer Copy over hand-rolled loops unless you need special control.

Useful wrappers

Helper Role
io.LimitReader(r, n) At most n bytes from r
io.MultiReader(r1, r2, …) Concatenate readers
io.MultiWriter(w1, w2, …) Fan-out writes
io.TeeReader(r, w) Read from r while writing a copy to w
io.NopCloser(r) Add a no-op Close to a bare Reader
io.SectionReader Window into an io.ReaderAt

Example — cap HTTP body size

const maxBody = 1 << 20 // 1 MiB
limited := io.LimitReader(r.Body, maxBody+1)
data, err := io.ReadAll(limited)
if err != nil {
    return err
}
if len(data) > maxBody {
    return fmt.Errorf("body too large")
}

Theory 3 — io.Pipe and backpressure

pr, pw := io.Pipe()
  • Writes to pw block until someone reads from pr (buffer is small).
  • Close / CloseWithError unblocks the other side.
  • Ideal for connecting a producer that wants a Writer to a consumer that wants a Reader without buffering everything.

Pattern

pr, pw := io.Pipe()
go func() {
    defer pw.Close()
    // encode JSON, gzip, etc. into pw
    _ = json.NewEncoder(pw).Encode(payload)
}()
// pass pr to http.Request.Body or io.Copy

Always handle goroutine lifecycle and errors (CloseWithError).


Theory 4 — Building a custom Reader

A transforming reader holds an inner io.Reader and rewrites bytes as they flow.

Contract checklist for custom Read:

  1. Copy at most len(p) bytes into p.
  2. Return (n, err) with n = bytes placed in p.
  3. Preserve EOF semantics of the inner reader.
  4. Do not retain p after return (caller owns the buffer).

Worked example — rot13 stream

package main

import (
    "fmt"
    "io"
    "os"
    "strings"
)

type rot13Reader struct {
    r io.Reader
}

func rot13(b byte) byte {
    switch {
    case b >= 'A' && b <= 'Z':
        return 'A' + (b-'A'+13)%26
    case b >= 'a' && b <= 'z':
        return 'a' + (b-'a'+13)%26
    default:
        return b
    }
}

func (r rot13Reader) Read(p []byte) (int, error) {
    n, err := r.r.Read(p)
    for i := 0; i < n; i++ {
        p[i] = rot13(p[i])
    }
    return n, err
}

func main() {
    src := strings.NewReader("Hello, Stage V!")
    var out strings.Builder
    if _, err := io.Copy(&out, rot13Reader{r: src}); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(out.String())
}

Run, then reverse by wrapping again (rot13 is involutive).


Theory 5 — ReaderFrom / WriterTo (efficiency awareness)

Optional interfaces:

type WriterTo interface {
    WriteTo(w Writer) (n int64, err error)
}

type ReaderFrom interface {
    ReadFrom(r Reader) (n int64, err error)
}

io.Copy checks for these and skips intermediate buffering when both sides support a fast path (e.g. *os.File ↔︎ net.TCPConn on some platforms via sendfile-like paths). You rarely implement these on day one—but knowing why Copy is preferred over naive loops matters.


Theory 6 — Discard and black-hole patterns

io.Copy(io.Discard, resp.Body) // drain body so connection can be reused
resp.Body.Close()

Leaving HTTP response bodies unread can stall connection reuse. Closing without draining is often wrong for keep-alive clients (Day 46 deepens this).


Lab 1 — Workspace + baseline Copy

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

Create copyfile.go:

package main

import (
    "fmt"
    "io"
    "os"
)

func copyFile(dstPath, srcPath string) (int64, error) {
    src, err := os.Open(srcPath)
    if err != nil {
        return 0, err
    }
    defer src.Close()

    dst, err := os.Create(dstPath)
    if err != nil {
        return 0, err
    }
    defer dst.Close()

    return io.Copy(dst, src)
}

func main() {
    if len(os.Args) != 3 {
        fmt.Fprintf(os.Stderr, "usage: %s <src> <dst>\n", os.Args[0])
        os.Exit(2)
    }
    n, err := copyFile(os.Args[2], os.Args[1])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("copied %d bytes\n", n)
}
echo 'stream me' > in.txt
go run . in.txt out.txt
cmp in.txt out.txt && echo OK

Lab 2 — Custom transforming Reader

Implement upperReader that uppercases ASCII letters as data is read (reuse the rot13 pattern).

Requirements:

  1. type upperReader struct { r io.Reader } with Read method.
  2. Unit test using strings.NewReader + io.ReadAll.
  3. Composition: io.LimitReader(upperReader{r: src}, 5) and assert only 5 bytes emerge, already uppercased.
// upper_test.go
package main

import (
    "io"
    "strings"
    "testing"
)

func TestUpperReader(t *testing.T) {
    r := upperReader{r: strings.NewReader("abC12")}
    got, err := io.ReadAll(r)
    if err != nil {
        t.Fatal(err)
    }
    if string(got) != "ABC12" {
        t.Fatalf("got %q", got)
    }
}

func TestUpperLimited(t *testing.T) {
    r := io.LimitReader(upperReader{r: strings.NewReader("hello world")}, 5)
    got, err := io.ReadAll(r)
    if err != nil {
        t.Fatal(err)
    }
    if string(got) != "HELLO" {
        t.Fatalf("got %q", got)
    }
}
go test ./...

Lab 3 — Tee + MultiWriter journal

Build a small tool that reads stdin, writes a copy to a log file and stdout:

mw := io.MultiWriter(os.Stdout, logFile)
if _, err := io.Copy(mw, os.Stdin); err != nil {
    // handle
}

Or use io.TeeReader(os.Stdin, logFile) then io.Copy(os.Stdout, tee).

Document which approach buffers less for your OS (both stream; difference is API shape).


Lab 4 — Pipe encode (stretch)

In a goroutine, write a multi-line payload through json.NewEncoder(pw). In main, io.Copy(os.Stdout, pr). Ensure:

  • Producer calls pw.Close() or CloseWithError
  • Consumer sees clean EOF
  • Injecting an encode error surfaces via CloseWithError

Common gotchas

Gotcha Fix
Ignoring n when err != nil on Read Always process p[:n] first
Treating io.EOF as hard failure EOF ends the stream successfully for Copy
Buffering multi-GB files in memory Stream with Copy / limited readers
Forgetting Close on ReadCloser defer resp.Body.Close() (and drain when needed)
Retaining the Read buffer Copy out data you need; never store p
Pipe without consumer Deadlock—always have a reader side
Assuming Write always writes all Check n and err; use io.Copy helpers

Mental model diagram (text)

[source] --Read--> [transform Reader] --Read--> [LimitReader] --Copy--> [Writer]
                         ^ composition: each layer only implements Read

Checkpoint

  • Explain Read returning (n>0, err) in one sentence
  • List five concrete types that implement io.Reader
  • Custom transforming Reader tested with go test
  • Demonstrated LimitReader composition
  • Used io.Copy instead of a hand-rolled full-buffer loop
  • Can explain when to use io.Pipe

Commit

git add .
git commit -m "day39: io interfaces, composition, transforming Reader"

Write three personal gotchas before continuing.


Tomorrow

Day 40 — bufio / bytes / strings: buffering for efficient line/word scanning, bytes.Buffer, and strings.Builder for a streaming record parser.


Day 40 — bufio, bytes & strings

Stage V · ~3h
Goal: Parse streams efficiently with bufio, manage byte slices with bytes, and build strings without quadratic concat—then ship a small record parser with tests.

Why this day exists

Raw io.Reader gives you arbitrary chunks. Real formats need lines, tokens, and delimited records. Doing that with tiny Read loops is error-prone and slow; loading everything into memory reintroduces Day 39’s failure mode.

bufio, bytes, and strings are the stdlib trio for text-ish I/O at the right level.


Theory 1 — Why buffer?

Each Read on a file or socket can be a syscall. bufio.Reader amortizes:

br := bufio.NewReader(r)   // default 4096-byte buffer
line, err := br.ReadString('\n')
Type Role
bufio.Reader Buffered reads; Peek, ReadByte, ReadSlice, ReadString
bufio.Writer Buffered writes; must Flush
bufio.Scanner Tokenize with split funcs (lines, words, custom)
bufio.ReadWriter Both sides

Scanner vs manual ReadString

Prefer Scanner for line/word protocols unless you need partial control or binary data:

sc := bufio.NewScanner(r)
// Optional: raise token size for long lines (default ~64KiB)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
    line := sc.Text() // or sc.Bytes() — careful: Bytes is overwritten next Scan
    _ = line
}
if err := sc.Err(); err != nil {
    // handle scan error (not EOF)
}
Warning

sc.Bytes() returns a slice owned by the scanner. Copy it (append([]byte(nil), sc.Bytes()...)) if you retain it past the next Scan.


Theory 2 — Split functions

sc.Split(bufio.ScanLines)  // default
sc.Split(bufio.ScanWords)
sc.Split(bufio.ScanRunes)
sc.Split(bufio.ScanBytes)

Custom split for comma-separated records (simplified):

func scanComma(data []byte, atEOF bool) (advance int, token []byte, err error) {
    if i := bytes.IndexByte(data, ','); i >= 0 {
        return i + 1, data[:i], nil
    }
    if atEOF && len(data) > 0 {
        return len(data), data, nil
    }
    return 0, nil, nil // need more data
}

Theory 3 — bytes package

bytes mirrors many strings functions for []byte without converting to string (and back).

Need API
Search Contains, Index, HasPrefix, Count
Cut Cut, CutPrefix, CutSuffix (modern, clear)
Fields Fields, Split, SplitN
Trim TrimSpace, TrimPrefix
Compare Equal, Compare
Buffer bytes.Buffer — grows, implements io.Reader/Writer

bytes.Buffer

var buf bytes.Buffer
buf.WriteString("hello")
buf.WriteByte(' ')
fmt.Fprintf(&buf, "day %d", 40)
s := buf.String() // copies; or buf.Bytes() (valid until next mutate)

Use Buffer when you need an in-memory Reader/Writer. For pure string assembly, prefer strings.Builder.


Theory 4 — strings package & Builder

var b strings.Builder
b.Grow(64) // optional hint
b.WriteString("id=")
b.WriteString(id)
out := b.String()

Never build large strings with s = s + piece in a loop—quadratic copies.

Useful modern helpers

before, after, ok := strings.Cut(line, "=")
prefix, ok := strings.CutPrefix(s, "Bearer ")

Cut is clearer than SplitN(..., 2) for “key/value once.”


Theory 5 — String vs []byte cost

  • Conversion string(bs) / []byte(s) copies data.
  • Hot parsers often stay in []byte until a final string is needed.
  • Scanner.Text() allocates a string; Bytes() does not (but is ephemeral).

For Day 40 labs, clarity beats micro-opts—but know the cost model.


Worked example — NDJSON-ish record parser

Format (one record per line):

name=ada role=eng active=true
name=alan role=research active=false
package parse

import (
    "bufio"
    "fmt"
    "io"
    "strings"
)

type Record struct {
    Name   string
    Role   string
    Active bool
}

func ParseRecords(r io.Reader) ([]Record, error) {
    sc := bufio.NewScanner(r)
    var out []Record
    lineNo := 0
    for sc.Scan() {
        lineNo++
        line := strings.TrimSpace(sc.Text())
        if line == "" || strings.HasPrefix(line, "#") {
            continue
        }
        rec, err := parseLine(line)
        if err != nil {
            return nil, fmt.Errorf("line %d: %w", lineNo, err)
        }
        out = append(out, rec)
    }
    return out, sc.Err()
}

func parseLine(line string) (Record, error) {
    var rec Record
    fields := strings.Fields(line)
    for _, f := range fields {
        key, val, ok := strings.Cut(f, "=")
        if !ok {
            return rec, fmt.Errorf("bad field %q", f)
        }
        switch key {
        case "name":
            rec.Name = val
        case "role":
            rec.Role = val
        case "active":
            switch val {
            case "true":
                rec.Active = true
            case "false":
                rec.Active = false
            default:
                return rec, fmt.Errorf("active=%q", val)
            }
        default:
            return rec, fmt.Errorf("unknown key %q", key)
        }
    }
    if rec.Name == "" {
        return rec, fmt.Errorf("missing name")
    }
    return rec, nil
}

Lab 1 — Setup + golden parse

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

Put parser in parse/parse.go and tests in parse/parse_test.go.

Sample input testdata/people.txt:

# staff
name=ada role=eng active=true
name=alan role=research active=false

Table-driven tests:

func TestParseRecords(t *testing.T) {
    in := "name=ada role=eng active=true\nname=x role=y active=maybe\n"
    _, err := ParseRecords(strings.NewReader(in))
    if err == nil {
        t.Fatal("expected error on bad active")
    }
}

Also test: empty lines, comments, missing name, unknown keys.

go test ./...

Lab 2 — Streaming CLI

go run . < people.txt
# print: ada    eng true

Use bufio.NewWriter(os.Stdout) and Flush in defer (or after loop). Compare wall time on a large generated file vs reading with os.ReadFile + strings.Split—observe memory with a big file if you wish (/usr/bin/time -l on macOS).

Generate:

python3 -c 'print("name=u role=r active=true\n"*200000)' > big.txt

Lab 3 — Custom Scanner split

Implement a split that yields paragraphs (blocks separated by blank lines). Print paragraph lengths. Edge cases: trailing content without final blank line; multiple blank lines.


Lab 4 — Builder report

From parsed records, emit a summary string with strings.Builder:

count=2 active=1

Benchmark (optional) Builder vs + concatenation for N=10_000 field appends:

go test -bench=Builder -benchmem ./...

Common gotchas

Gotcha Fix
Forgetting sc.Err() Always check after the loop
Keeping sc.Bytes() Copy if storing
Token too long sc.Buffer(buf, max)
Forgetting Writer.Flush defer bw.Flush() and check error
strings.Split empty edge Prefer Cut / Fields for clarity
Quadratic s += strings.Builder
Assuming Latin-1 lines UTF-8 is fine; runes need ScanRunes or utf8

Checkpoint

  • Explain why bufio exists in terms of syscalls
  • Parser handles comments, blanks, and bad fields with line numbers
  • Tests cover at least four failure modes
  • CLI streams stdin → stdout without ReadAll of whole file
  • Can state when to use bytes.Buffer vs strings.Builder
  • Know Scanner’s default max token size risk

Commit

git add .
git commit -m "day40: bufio scanner parser + bytes/strings tools"

Write three personal gotchas before continuing.


Tomorrow

Day 41 — encoding/json: tags, omitempty, custom marshalers, streaming encode/decode, and awareness only of the json/v2 direction—without rewriting production code onto experimental APIs.


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.