skip to content

Should a request type validate inside its UnmarshalJSON method or in a separate Validate method?

level: middleimportance: should knowfreq 48%

answer

  1. one guards a door, one guards the value
  2. which construction paths skip it?
  3. malformed input versus unacceptable input
  4. forgetting is fixed by one helper
  5. scalars parse; request structs validate

basics

~20 s

Prefer a separate Validate method. Validation inside UnmarshalJSON runs only when the value arrives as JSON, blurs malformed syntax with invalid content, and can leave the receiver half-populated. A Validate method covers every construction path and is trivial to test.

solid answer

~50 s

`UnmarshalJSON([]byte) error` is called by `encoding/json` for any value of that type it decodes, at any nesting depth, and the error you return comes back out of `Decode`. That makes it tempting: a check you cannot skip. But it fires only on the JSON path - a struct built in a test or filled from a database row is never checked - and implementing it means you take over decoding that type's fields, which on a request struct is more machinery than the rule is worth. The caller also loses the difference between malformed and merely unacceptable input. A `Validate() error` method is format-independent, easy to unit-test and able to report several failing fields at once; its one weakness, that a call site can forget it, you fix by funnelling every decode through one helper that decodes then validates. Keep `UnmarshalJSON` for small scalar types where parsing is the invariant, such as an enum or a duration string.

code

go · 15 lines
go
type CreateUser struct {
	Email string `json:"email"`
	Age   int    `json:"age"`
}

func (c CreateUser) Validate() error {
	var errs []error
	if c.Email == "" {
		errs = append(errs, errors.New("email is required"))
	}
	if c.Age < 13 {
		errs = append(errs, errors.New("age must be at least 13"))
	}
	return errors.Join(errs...)
}

go deeper

for a junior

Know that a type can customise its own JSON decoding with a method named UnmarshalJSON, and that a Validate method is just an ordinary method you have to remember to call after decoding.

for a middle

Explain when each one runs: UnmarshalJSON fires on any JSON decode of that type at any nesting depth, while a Validate method fires only where a caller invokes it but applies however the value was built. Name the costs of each.

for a senior

Demonstrate the boundary design, not the preference: one decode-then-validate entry point per service, errors that distinguish malformed from unacceptable, and per-field messages the client can act on.

for a principal

Own the convention across teams - whether request rules live in a shared helper, how per-field errors are shaped for API consumers, and what you accept as the cost when a service quietly does its own thing.

## The two places a check can live When a service decodes a request struct, the rules that make the request acceptable can be enforced in one of two places. **Inside the type's own decoding.** If a type implements `json.Unmarshaler` - that is, a method `UnmarshalJSON(data []byte) error` - `encoding/json` hands that method the raw bytes of the value instead of decoding it in the ordinary way. It is called for the top-level value, for a field of that type nested any number of levels down, and for each element when the type appears in a slice or a map. Whatever error you return travels back out of `json.Unmarshal` or `Decoder.Decode` to the caller. **After decoding, as its own step.** The type stays a plain struct, decoded normally, and carries a method such as `Validate() error` that the caller invokes once the decode returns. ## What validating inside UnmarshalJSON buys and costs The appeal is real: a caller cannot receive a decoded value that skipped the check, because the check is part of decoding. For a small type whose whole meaning is a parse - a status enum that must be one of four strings, a duration written as `"30s"`, an id with a fixed shape - that is exactly right. Parsing and validating are the same act, and there is no useful intermediate state. For a request struct the costs stack up: - **It only guards one door.** The method runs when the value comes from JSON. A struct literal in a test, a value assembled by another package, a row scanned out of a database, a payload decoded from a different format - none of them go anywhere near it. So you still need the rules expressed somewhere reusable, or you have two copies. - **You take over decoding.** Implementing the method means the package no longer decodes that type's fields for you; you have to do it, and doing it on a struct with many fields is more machinery than the check is worth. - **The caller loses a distinction.** "The body is not valid JSON", "the body has a string where a number belongs" and "the body is fine but the age is negative" all come back as an error from the same call. The first two are almost always a 400 with a generic message; the third usually wants a field-specific message. You can recover the difference with a typed error and `errors.As`, but you have to build that yourself. - **Partial population.** If the method fails part-way through, the receiver may already hold some decoded fields. A caller that ignores the error and uses the value gets a half-filled struct rather than a zero one. - **Testing is heavier.** Every test of a rule has to go through a JSON document rather than calling one method on a value. ## What a separate Validate method buys and costs `func (c CreateUser) Validate() error` is ordinary Go. It is independent of the wire format, so the same rules apply however the value was built. It is directly unit-testable with a table of values. It can accumulate problems - collect one error per bad field and return them together with `errors.Join`, so the client fixes everything in one round trip instead of five. It composes: a nested type's `Validate` is called by its parent's. Its one real weakness is that it is a convention, and conventions get forgotten. The answer is not to move the check back into decoding, it is to remove the opportunity to forget: give the service exactly one function that turns a request body into a value, and have that function decode and then validate. Every handler calls it; no handler can call half of it. ``` type validator interface{ Validate() error } func decodeRequest[T validator](r io.Reader) (T, error) { var v T if err := json.NewDecoder(r).Decode(&v); err != nil { return v, err } return v, v.Validate() } ``` With that helper in place, the two failure modes stay distinguishable at the call site - a decode error and a validation error come from different lines - and a reviewer can check the property by grepping for the helper rather than by reading every handler. ## How to answer the question in an interview Say where each belongs. `UnmarshalJSON` when the type is a scalar whose parse is its invariant, and when you would otherwise be able to construct a meaningless value of it. A `Validate` method, funnelled through a single decode helper, for request structs - because the rules outlive the wire format and the errors want to be reported per field. The weak answer is "always one" or "always the other"; the strong one names the property each choice actually gives you and how you get the missing half back.

  • When is UnmarshalJSON the right home for a rule?
    When the type is a small scalar whose parse is its invariant - a status enum that must be one of a fixed set, a duration written as a string, an identifier with a fixed shape. There is no useful half-parsed state for such a type, the decoding you take over is a few lines, and making an invalid value undecodable is exactly the guarantee you want.
  • How do you report several field problems from one request instead of only the first?
    Collect an error per failing field and return them together with `errors.Join`, added in Go 1.20. The joined error's message lists each one, and `errors.Is` and `errors.As` still match any of the wrapped errors, so a handler can look for a typed field error and build a per-field 400 body. The client then fixes everything in one round trip.
  • A team keeps forgetting to call Validate after decoding. What do you change?
    Not the rules - the shape of the boundary. Expose one function that decodes and then validates, and make it the only way a handler gets a request value out of a body. If more force is wanted, keep the wire struct unexported in a package whose only exported entry point is that function, so a handler literally cannot obtain a decoded-but-unchecked value.

saying these in an interview costs you the question

  • Assumes UnmarshalJSON runs for values built in code
  • Says UnmarshalJSON is called only for the top-level value
  • Puts every request rule inside UnmarshalJSON on a large struct
  • Cannot separate malformed input from unacceptable input
  • Treats a forgotten Validate call as unavoidable