JavaRush /Courses /Go SELF /JSON errors: how to produce useful messages

JSON errors: how to produce useful messages

Go SELF
Level 46, Lesson 4
Available

1. Why “just print err” doesn’t help

When you start writing programs, you really want to do this: “if there’s an error, print err and that’s it.” It’s natural: at least the program isn’t silent. But pretty quickly it turns out that the bare error text is often either too technical (invalid character '}' looking for beginning of object key string), or too generic (unexpected EOF), or not helpful at all (cannot unmarshal number into Go struct field ...).

In Go, it’s important to remember the idea errors are values—errors aren’t magic spells; they’re ordinary values that you can analyze and improve. So a good strategy looks like this: inside the program we preserve the cause as precisely as possible (so code can recognize it), and at the program boundary (CLI, import, config) we turn it into a message a human can understand. It’s like translation: inside the team you speak “as developers,” but to the user you speak plain language.

2. Operation context and error wrapping

Imagine you get an error unexpected EOF. Where did it come from? From a file? From stdin? Were we reading tasks.json? Or config.json? Or is it a truncated chunk of data in the middle?

This is where operation context shows up: when an error bubbles up the stack, each layer adds “what it was doing.” In Go this is usually done via fmt.Errorf("...: %w", err). The key point is exactly "%w": this is how we wrap the error while keeping the original cause inside the chain, so it can be recognized via errors.Is/errors.As.

Mini example: read the tasks JSON and add context:

package main

import "fmt"

func loadTasksJSON() error {
	err := fmt.Errorf("unexpected EOF") // imagine this came from json.Decoder
	return fmt.Errorf("read tasks file: %w", err)
}

If you print err.Error(), a person will see read tasks file: unexpected EOF. That’s already better: it’s clear where the error happened.

But there’s an important nuance: by wrapping an error, you make it part of your contract. If you return an error from a foreign package to the outside (for example, sql.ErrNoRows or an internal DB error type), you’re effectively promising users of your code that this cause will always be there. Go blog posts emphasize this separately: wrapping is an API decision, not just “pretty text.”

A practical rule of thumb for our level: if the error is an “implementation detail” and you don’t want to “sell it externally,” you can add context via "%v" (not via "%w"). But at the JSON input boundary, the cause is most often useful, because it explains what exactly is wrong with the incoming data.

"%w" vs "%v": why this is a contract decision

A very common beginner mistake is to think "%w" is needed simply “to look nice.” In reality, "%w" is not about aesthetics; it’s about enabling higher-level code to say: “aha, inside there is a *json.SyntaxError” and to extract details via errors.As.

Go 1.13 standardized wrapping: "%w" makes an error unwrap-able, and errors.Is/errors.As start seeing the whole chain. This is convenient, but it requires discipline: if you wrap someone else’s error and return it outward, you are promising that this cause can be recognized.

Materials on errors emphasize that this can “leak” across abstraction boundaries: once an implementation detail becomes accessible, you can no longer easily replace it without breaking users.

For our JSON input scenario, wrapping is almost always justified: the error cause is part of the input contract. If the JSON is broken, that’s not an “implementation detail”; that’s the essence of the problem.

3. A user message and a diagnostic cause

In application code there are almost always two truths about an error. The first truth is a short human message: “the tasks file contains invalid JSON.” The second truth is the technical cause: “SyntaxError at offset 183.”

A classic Go technique is to have an error struct that separately stores what you show the user and what you keep as the cause. In older but very illustrative Go code examples, this is done via appError with fields Message (for the user) and Error (for logs/diagnostics).

We’ll do a similar idea, just simpler and without HTTP.

A minimal “user-facing” error with unwrap

package main

type InputError struct {
	Msg string
	Err error
}

func (e *InputError) Error() string { return e.Msg }
func (e *InputError) Unwrap() error { return e.Err }

Notice the style: Error() returns a short message that you’re not ashamed to show a human. And Unwrap() preserves the cause for errors.As/errors.Is.

A small JSON error “translator”

Now let’s assemble the core idea: create a function that takes an error (most likely already wrapped with context), tries to extract useful details, and returns an *InputError with a human Msg and the original cause inside.

Let’s start with a simple skeleton. It’s intentionally short so you can rewrite it yourself without the feeling of “I’m reading someone else’s thesis.”

package main

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

func wrapJSONInput(err error) error {
	if errors.Is(err, io.EOF) {
		return &InputError{Msg: "expected JSON, but the input is empty", Err: err}
	}

	var se *json.SyntaxError
	if errors.As(err, &se) {
		msg := fmt.Sprintf("invalid JSON (syntax), position %d", se.Offset)
		return &InputError{Msg: msg, Err: err}
	}

	return &InputError{Msg: "failed to read JSON", Err: err}
}

Pay attention to the style: messages are short, no period at the end, no “Error:”. And yes—your program now sounds like a normal assistant, not like a suffering printer.

4. Typed errors in encoding/json

Errors from encoding/json are not just strings. Some of them have concrete types, and that’s a real gift: you can extract structured details and make the message smarter.

Most useful in practice:

  • *json.SyntaxError — JSON syntax is broken. The important field is Offset (position in bytes). The idea of using Offset for a more precise message (for example, line/col) appears even in official materials about error handling.
  • *json.UnmarshalTypeError — the JSON is valid, but the type doesn’t match your struct (for example, "id": "abc" instead of a number). It often contains the field name and information about what was expected.

The recognition skeleton using errors.As looks like this:

package main

import (
	"encoding/json"
	"errors"
)

func isSyntax(err error) bool {
	var se *json.SyntaxError
	return errors.As(err, &se)
}

And here is how you can extract details:

package main

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

func syntaxOffset(err error) (int64, bool) {
	var se *json.SyntaxError
	if !errors.As(err, &se) {
		return 0, false
	}
	return se.Offset, true
}

The important habit here: we don’t compare the text err.Error(), we look at the type. It’s more reliable and survives changes in error wording.

5. Offset, line, and column

One thing is to say “position 183,” another is “line 12, column 5.” Line/column is clearer to a human. But there’s a nuance: Offset is a position in bytes, and to compute line and column you need the original text in full.

If you’re reading a small config or a small JSON file, you can afford to read everything into memory (for example, via io.ReadAll) specifically for good diagnostics. This is a reasonable trade-off: user experience is often more important than micro-optimizations.

Mini function for line/col from a byte slice:

package main

func lineCol(data []byte, offset int64) (line, col int) {
	line, col = 1, 1
	for i := int64(0); i < offset-1 && i < int64(len(data)); i++ {
		if data[i] == '\n' {
			line, col = line+1, 1
		} else {
			col++
		}
	}
	return line, col
}

Yes, this doesn’t properly account for runes and UTF-8, but for byte-based JSON diagnostics it’s usually enough: encoding/json JSON errors also count offsets in bytes.

6. Integrating into an application: strict TaskFile decode

Now let’s tie everything to our tutorial application—a simple task manager. Let’s say we have a “task file” format where we store a version and a list of tasks.

package main

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

type TaskFile struct {
	Version int    `json:"version"`
	Tasks   []Task `json:"tasks"`
}

We strictly read one JSON document, and in case of an error we add operation context, and then at the boundary we turn it into an *InputError.

First: strict decode + context:

package main

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

func decodeTaskFile(r io.Reader) (TaskFile, error) {
	dec := json.NewDecoder(r)
	dec.DisallowUnknownFields()

	var tf TaskFile
	if err := dec.Decode(&tf); err != nil {
		return TaskFile{}, fmt.Errorf("decode task file: %w", err)
	}
	return tf, nil
}

Now: the “application boundary”—a conditional main, where we decide what to show the user.

package main

import (
	"fmt"
	"os"
)

func main() {
	_, err := decodeTaskFile(os.Stdin)
	if err != nil {
		err = wrapJSONInput(err)
		fmt.Fprintln(os.Stderr, "Error:", err) // Error: failed to read JSON
		os.Exit(1)
	}
	fmt.Println("OK") // OK
}

The point is that decodeTaskFile doesn’t try to be “friendly.” Its job is to correctly read data and preserve the cause. Friendliness is the job of the “boundary” (CLI). This approach is very close to the idea “show the user a simple message, but keep details for diagnostics.”

7. Common mistakes when presenting JSON errors

Mistake #1: returning a “naked” error without context.
If you just do return err, then at the top level you see unexpected EOF and start playing detective: EOF where, why, at what step? It’s much better to add context at each layer: "decode task file", "read config", "unmarshal versioned envelope". This turns the error string into a story of actions rather than a riddle.

Mistake #2: using "%v" instead of "%w" where you want to recognize the cause.
The phrase "decode task file: …" will look the same with "%v" and with "%w", and that’s tricky. But only "%w" will preserve the cause so that higher up you can do errors.As(err, &se) and get the Offset from *json.SyntaxError. Wrapping is not cosmetics; it’s a mechanism.

Mistake #3: classifying errors by err.Error() and substrings when a type exists.
Error strings can change, be localized, be expanded. If you can recognize *json.SyntaxError or *json.UnmarshalTypeError via errors.As, do it—it’s much more reliable. And the very idea “an error is a value; you can analyze it” is considered fundamental in Go.

Mistake #4: showing the user overly technical details.
The message cannot unmarshal number into Go struct field Task.id of type string is very informative for a developer, but for a user it’s noise and a perfectly valid reason to start hating your program. It’s better to show “field id must be a number,” and keep the technical cause inside Unwrap() so you can retrieve it during debugging.

Mistake #5: not handling the empty-input case (io.EOF).
io.EOF often means “the input is empty,” not “something broke.” If your function contract expects JSON, empty input is a clear user/file error. If you don’t handle this case, the user will get a strange EOF and won’t understand what happened.

1
Task
Go SELF, level 46, lesson 4
Locked
Decoding context
Decoding context
1
Task
Go SELF, level 46, lesson 4
Locked
Human-friendly error
Human-friendly error
1
Task
Go SELF, level 46, lesson 4
Locked
Error translation
Error translation
1
Task
Go SELF, level 46, lesson 4
Locked
Error coordinates
Error coordinates
1
Survey/quiz
JSON (advanced), 46 level, 4 lesson
Unavailable
JSON (advanced)
JSON (advanced)
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION