JavaRush /Courses /Go SELF /JSON decoding and validation errors

JSON decoding and validation errors

Go SELF
Level 59, Lesson 3
Available

1. Input errors are part of the API contract

When you write a server “just for yourself”, it’s very tempting to think like this: “If the client messed up, let them suffer.” But as soon as your API has at least one real consumer (frontend, script, integration, mobile app), you discover an unpleasant thing: errors are also an interface, just like successful responses.

Client code can’t read your mind. It reads the HTTP status, headers, and JSON. If today you respond to “broken JSON” with a string, tomorrow with HTML, and the day after tomorrow with JSON that has a different field, then you break the client just as effectively as if you renamed the title field to taskTitle. Go in general puts strong emphasis on compatibility and avoiding breaking API consumers unless absolutely necessary.

So we fix the rule: for validation/decoding errors, the client always gets a single unified error envelope, and they don’t have to guess “what shape the error will come in today”.

2. Why http.Error breaks a unified JSON API format

http.Error looks like a super-convenient “make an error for me” button. And it really is convenient… until you’re building a JSON API. Historically, there are many Go examples where a handler does something like http.Error(w, err.Error(), 500), and it seems “Good enough.”

The problem is that http.Error (and especially http.Error(w, err.Error(), 500)) almost guaranteedly leads to three unpleasant things at the same time.

The first issue is format. http.Error writes plain text (often as text/plain; charset=utf-8, plus it adds a newline). If your API returns JSON in all other cases, the client ends up playing “guess the tune”: on success — JSON, on error — a string.

The second issue is detail leaks. If you return err.Error() to the outside, you often return internals: struct field names, parsing details, sometimes bits of configuration, and other “open secrets”. This isn’t always directly dangerous, but it’s almost always sloppy.

The third issue is copy-paste. As soon as you have more than two handlers, http.Error multiplies faster than rabbits: in each place, slightly different text, slightly different logic, slightly different statuses. The classic motivation to centralize error handling appears precisely because of this repetition.

From this point on, we agree: we do not use http.Error in our JSON API. At all. Not even “just for a second”.

3. A single error envelope: the error format

To keep talking the same language, let’s fix the format that the client expects always when something goes wrong:

{
  "error": {
    "code": "validation",
    "message": "invalid request",
    "fields": {
      "title": "must not be empty"
    }
  }
}

Where code is a short machine code (convenient for automated handling), message is a general human-readable message, and fields is an object with errors for specific fields (we use it only for validation errors; otherwise the field is absent).

In Go it usually looks like this:

package main

type APIError struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

type ErrorEnvelope struct {
	Error APIError `json:"error"`
}

Note omitempty: if Fields == nil, then fields will not appear in JSON. This makes the response cleaner and clearer: no field errors — no fields field.

4. Decode vs validation: two different problems

A very common beginner mistake is to throw everything into one pile: “If something is wrong — 400.” Formally, this often “works.” But when you debug a client or write documentation, the difference becomes fundamental.

In our server, there are two separate layers of input checking.

The first layer is decoding: we read r.Body and try to understand whether it is valid JSON of the required shape. Errors here are usually technical: broken syntax, unexpected end, extra fields, wrong type (string instead of number).

The second layer is validation: the JSON is correct, the structure matched, but the data doesn’t satisfy domain/contract rules. For example, title is empty or consists of whitespace. This is no longer “broken JSON”, but “bad data”.

It’s convenient to think of this as a pipeline:

flowchart TD
    A[HTTP request] --> B[Decode JSON]
    B -->|decode ok| C[Validate fields]
    B -->|decode error| E[400 + error envelope: body/...]
    C -->|valid ok| D[Business logic]
    C -->|validation error| F[400 + error envelope: fields]

The key idea here: both decode errors and validation errors are 400, but fields and the meaning of the messages differ.

5. Example: POST /tasks and bad input

So we don’t discuss abstractions in a vacuum, we’ll continue our educational tasks API. Today we’re interested in creating a task via JSON.

The request will look like this:

{ "title": "Buy milk" }

For it, we’ll create a DTO (request struct):

package main

type createTaskRequest struct {
	Title string `json:"title"`
}

And here are the situations we must be able to distinguish without losing our composure:

  • the body is empty (the client sent literally nothing);
  • JSON is broken ({ "title": "Buy milk");
  • JSON is correct, but a field has an unexpected type ({ "title": 123 });
  • JSON contains extra fields ({ "title": "Buy milk", "lol": true });
  • JSON is correct, but title is empty or whitespace-only ({ "title": " " });
  • the body is too large (the client decided to send “War and Peace” as the task title).

If you learn to handle this consistently on a single endpoint, the rest of the endpoints will be built using the same template. And if you don’t, your clients will start “learning” by trial and error… and usually they learn on your production environment.

6. How to classify json.Decoder errors

encoding/json is a pretty honest package: it doesn’t hide what exactly went wrong. Moreover, many errors there are typed (for example, *json.SyntaxError), and Go directly encourages such things: errors are values, they can have types, and it’s fine to use that.

But there’s a nuance: to cleanly distinguish error kinds, we need a minimal “translation table” from the decoder’s internal errors to our external contract (the error envelope).

We’ll check errors via errors.As/errors.Is. This is important because errors can be wrapped: you added context via fmt.Errorf("decode: %w", err), but you still want to know the original error type. That’s exactly what errors.Is and errors.As exist for in Go.

Mini table: what happened and what we respond

Let’s summarize the core idea in a table. These are not “the only correct wordings”, but a good stable starting point.

Situation (internal) How to detect in Go What we return to the client
Empty body err == io.EOF on the first Decode 400 + fields.body = "must not be empty"
Broken JSON (syntax) var e *json.SyntaxError; errors.As(err, &e) 400 + fields.body = "invalid json"
Invalid field type var e *json.UnmarshalTypeError; errors.As(err, &e) 400 + fields.<field> = "invalid type"
Unknown fields DisallowUnknownFields() + check the text "json: unknown field ..." 400 + fields.body = "unknown field" (or more specific)
Multiple JSON values in a row the second Decode returned not io.EOF 400 + fields.body = "must contain single json object"
Body too large var e *http.MaxBytesError; errors.As(err, &e) 400 + fields.body = "too large"

The point is that the client always sees the same external format, but the “payload” (fields) tells them what exactly they need to fix.

7. decodeJSON: safely read the body and return a client-facing error

Now we’ll assemble a function that does three things at once: limits the body size, strictly decodes JSON, and enforces the “exactly one JSON object” rule. Important: this function does not write the response itself yet. It returns an error that the handler can turn into an error envelope.

First, let’s define our own small error for “input problems”:

package main

type inputError struct {
	Fields map[string]string
}

func (e inputError) Error() string {
	return "invalid request"
}

This error doesn’t try to be “smart”: its job is to store Fields. And we only need the Error() string because otherwise this wouldn’t implement error.

Now the decoding function itself:

package main

import (
	"encoding/json"
	"net/http"
)

func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	const maxBody = 1 << 20 // 1 MiB
	r.Body = http.MaxBytesReader(w, r.Body, maxBody)
	defer r.Body.Close()

	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	if err := decodeOne(dec, dst); err != nil {
		return classifyDecodeError(err)
	}
	return nil
}

Decode and check the “tail”:

package main

import (
	"encoding/json"
	"errors"
	"io"
)

func decodeOne(dec *json.Decoder, dst any) error {
	if err := dec.Decode(dst); err != nil {
		return err
	}
	if err := dec.Decode(&struct{}{}); err != io.EOF {
		return errors.New("extra data after json")
	}
	return nil
}

Yes, we deliberately split it into two functions so the pieces are small and readable.

Now the “translator” from decode errors to inputError:

package main

import (
	"encoding/json"
	"errors"
	"io"
	"net/http"
)

func classifyDecodeError(err error) error {
	var maxErr *http.MaxBytesError
	if errors.As(err, &maxErr) {
		return inputError{Fields: map[string]string{"body": "too large"}}
	}
	if errors.Is(err, io.EOF) {
		return inputError{Fields: map[string]string{"body": "must not be empty"}}
	}
	var typeErr *json.UnmarshalTypeError
	if errors.As(err, &typeErr) && typeErr.Field != "" {
		return inputError{Fields: map[string]string{typeErr.Field: "invalid type"}}
	}
	return inputError{Fields: map[string]string{"body": "invalid json"}}
}

There are two “teaching compromises” here, and that’s fine.

  • First, we don’t try to parse “unknown field X” perfectly. You can make it more precise, but as a first approximation a generic message like "invalid json" or "unknown field" is enough.
  • Second, we don’t expose err.Error() to the outside — and this is intentional. Even if you really want to “help the client”. Real help should be in a stable contract, not in a random string from an internal library.

8. Field validation after decode

Validation is the moment where you stop being a “JSON parser” and become a “bouncer at the domain entrance.” The domain usually doesn’t care whether the JSON looked pretty. The domain cares whether the values make sense.

So we validate separately and explicitly. For createTaskRequest, the rule is simple: title must not be empty or whitespace-only.

package main

import "strings"

func validateCreateTask(req createTaskRequest) error {
	title := strings.TrimSpace(req.Title)
	if title == "" {
		return inputError{Fields: map[string]string{"title": "must not be empty"}}
	}
	return nil
}

Why do we return inputError again? Because the handler doesn’t care where exactly the input failed — at decode or at validation. If it’s a “client error”, the handler responds with 400 and an error envelope.

9. Handler POST /tasks: always respond via an envelope

Now we’ll assemble the POST /tasks handler that uses our functions. For simplicity: if everything is good, we’ll return 201 Created and a small JSON { "ok": true }. In a real project we’d return a DTO for the created task, but our goal here is handling input errors.

First, a minimal function for writing the error envelope (yes, it’s a small helper; we’re not building a “universal combine harvester”, we just don’t want to repeat the JSON structure three times):

package main

import (
	"encoding/json"
	"net/http"
)

func writeValidationError(w http.ResponseWriter, fields map[string]string) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusBadRequest)

	_ = json.NewEncoder(w).Encode(ErrorEnvelope{
		Error: APIError{
			Code:    "validation",
			Message: "invalid request",
			Fields:  fields,
		},
	})
}

Now the handler itself:

package main

import (
	"encoding/json"
	"errors"
	"net/http"
)

func handleCreateTask(w http.ResponseWriter, r *http.Request) {
	var req createTaskRequest

	if err := decodeJSON(w, r, &req); err != nil {
		var in inputError
		if errors.As(err, &in) {
			writeValidationError(w, in.Fields)
			return
		}
		writeValidationError(w, map[string]string{"body": "invalid json"})
		return
	}

	if err := validateCreateTask(req); err != nil {
		var in inputError
		if errors.As(err, &in) {
			writeValidationError(w, in.Fields)
			return
		}
		writeValidationError(w, map[string]string{"body": "invalid request"})
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}

Yes, there’s a bit of “school-style” redundancy here: we check errors.As twice. But the logic is linear and clear: decode → validate → success.

And one more important point: note that in case of input problems we always respond with JSON in one format. The client doesn’t need to figure out what we used internally.

What you can expose to the client vs what you should keep in logs

When you choose what to put into an error, you are effectively choosing what becomes part of the API. In Go this is visible even at the level of regular functions: if you wrap an internal error and give the client a way to recognize it, you are implicitly promising that this internal detail is now stable. And that can become a problem if you later want to change the implementation.

In an HTTP API, the logic is similar. If you expose “raw” texts from encoding/json or “raw” database errors, the client may start relying on them (even if they swear they won’t). And a month later you discover you can’t change a library or improve wording because someone will break.

So a good rule is: externally, return stable codes and predictable fields, and keep details to yourself (for example, in logs). Even if you don’t have logs yet — it’s still better not to create “leaks of future problems”.

10. Common mistakes

Mistake #1: respond to broken JSON via http.Error, but respond to validation via JSON.
This gives the client two error formats. At best they start writing conditional branches like “if JSON parsed — one thing, otherwise — another.” At worst they crash while parsing the response. For an API it’s much better when the error format is one and always JSON.

Mistake #2: expose err.Error() from encoding/json.
Even if it seems “useful to the client”, in practice you’re exposing implementation details and unstable wording. The client remembers them, frontend tests lock them in, and you paint yourself into a corner.

Mistake #3: mix decode and validation together without structure.
If you validate fields first and only then try to decode JSON (or do it in different places), it’s easy to get weird bugs: r.Body is read once, and the second time it’s already “empty”. It’s better to keep the “decode → validate” protocol always in the same order and in the same place.

Mistake #4: don’t enforce the “exactly one JSON object” rule.
If you do only one Decode, then a request with a body like {...}{...} may be partially “swallowed”, and the garbage goes unnoticed. This is rare with normal clients, but common in tests, manual requests, and attackers (yes, they do exist sometimes).

Mistake #5: forget about a body size limit.
Without http.MaxBytesReader you allow the client to send a huge body that will be read and consume memory/time. Even if you’re not thinking about attacks, at least think about a client bug: “oops, we sent the wrong field, and it turned out to be megabytes long.”

1
Task
Go SELF, level 59, lesson 3
Locked
JSON ping
JSON ping
1
Task
Go SELF, level 59, lesson 3
Locked
Polite greeting
Polite greeting
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION