Skip to content

Day 099 — Request Validation

Month 4 · Week 3 · ⬅ Day 098 · Day 100 ➡ · Journal index

🎯 Learning Objective

Validate a decoded request body and return all field errors at once with the right status code, separating "I can't parse this" from "this breaks a rule".

📚 Topics

  • Decode-then-validate; collecting FieldErrors
  • 400 vs 422; net/mail.ParseAddress; trimming before measuring

📖 Reading / Sources

📝 Notes

  • Two failure classes, two codes. A body that won't decode is a 400 Bad Request (syntax). A body that decodes but violates a business rule is 422 Unprocessable Entity (semantics). Don't collapse them. → [[http-status-codes]]
  • Validate after decoding, on the request type — a Validate() method on the struct, not buried in the handler, so it is unit-testable with no server.
  • Collect every error, don't bail on the first. Append to a map[string]string keyed by field; one bad round-trip beats N. → [[fail-collect]]
  • Trim before you measure. strings.TrimSpace(name) == "" catches the all-whitespace name that a naive len(name) > 0 would wave through.
  • Don't hand-roll an email regex. mail.ParseAddress is the stdlib check; it accepts what an SMTP layer would actually accept.
  • A map (not []error) gives a stable JSON shape and one message per field; keep the first message set per field so general rules win. → [[idempotent-errors]]

💻 Code Examples

type FieldErrors map[string]string

func (fe FieldErrors) Add(field, msg string) {
    if _, exists := fe[field]; !exists { // keep the first error per field
        fe[field] = msg
    }
}

func (r CreateUserRequest) Validate() FieldErrors {
    fe := FieldErrors{}
    if strings.TrimSpace(r.Name) == "" {
        fe.Add("name", "is required")
    }
    if _, err := mail.ParseAddress(r.Email); err != nil {
        fe.Add("email", "must be a valid email address")
    }
    if r.Age < 18 {
        fe.Add("age", "must be at least 18")
    }
    return fe // len==0 means valid
}

Full code: examples/month-04/validation/main.go · Run: go run ./examples/month-04/validation

🏋️ Exercises / Practice

Exercise Status Link
Validate() collecting all field errors exercises/month-04/week-3/validate
First-error-wins Add semantics exercises/month-04/week-3/validate

🐛 Mistakes Made

  • Returned 400 for a valid-JSON-but-invalid-age body. Switched to 422.
  • Bailed on the first error; clients had to retry repeatedly. Now I collect all.

❓ Open Questions

  • When does a reflection/tag-driven validator (go-playground/validator) earn its keep over hand-written Validate() methods? (Answer: many fields / shared rule sets; until then hand-written is clearer and dependency-free.)

🧠 Active Recall (answer without looking)

  1. Q: Malformed JSON vs a well-formed body that breaks a rule — which status each?

    A Malformed/undecodable JSON → `400 Bad Request`. Decodes fine but violates a business rule → `422 Unprocessable Entity`.

  2. Q: Why return a map[string]string of errors instead of stopping at the first?

    A It reports every problem in one response (better UX, fewer round-trips) and gives a stable, parseable JSON shape keyed by field name.

🪶 Feynman Reflection

Parsing answers "is this even JSON I understand?" Validation answers "do the values make sense?" They are different questions with different HTTP codes. I gather all the value complaints into one labelled list so the caller fixes everything in a single edit.

🕳️ Knowledge Gaps

  • Cross-field rules (e.g. endDate after startDate) and how to surface them.

✅ Summary

I can decode strictly, validate on the request type, collect every field error, and answer with 400 vs 422 correctly.

⏭️ Next Steps / Prep for Tomorrow

  • Day 100: per-request structured logging with log/slog.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: feat(examples): request validation with collected field errors (day 099)