JavaRush /Courses /Go SELF /Multi-error in a CLI: a list of issues without a “wall of...

Multi-error in a CLI: a list of issues without a “wall of text”

Go SELF
Level 55, Lesson 3
Available

1. Why an error “wall of text” is a UX bug

When you start validating command arguments, it’s tempting to “report honestly”: collect everything into one long string and print it to the user. Formally it’s informative, but in practice a person sees a wall of text, gets lost, fixes one thing, runs again, and gets the next one. In the end, instead of “the program helps,” you get “the program grumbles.”

Imagine a command from our training app tasker (a hypothetical task tracker):

tasker add --title="" --priority=999 --due="yesterday-ish"

If we print it like this:

validate args: title is required; priority must be 1..5; due must be YYYY-MM-DD

it already looks a bit more human, but it’s still hard to scan. And if you add Op, technical details, error sources, system messages—you’ll get that same “wall of text.”

Our goal: a short summary + a bullet list of issues, so the user can understand in a single run what needs to be fixed.

When multi-error makes sense

Multi-error is not “throw everything into a bag.” It’s useful when an operation has several independent issues that the user can fix in parallel.

The most common case is argument validation: the title is empty, the priority is out of range, the date failed to parse. It’s better for the user to see everything at once than to play “guess the next error.”

But for runtime errors (I/O, network, permissions), multi-error is usually less useful: more often it’s “it failed and it failed,” and trying to continue just to collect five more failures is questionable. So by default, multi-error is our friend specifically in validation.

2. errors.Join and collecting issues in a stable order

Go has a standard mechanism for combining errors: errors.Join. It creates a single error that holds multiple causes.

This is convenient for two reasons.

First, externally, by contract, you still return a single error, which fits Go function design perfectly.

Second, internally, the structure remains: you can “unpack” such an error as a list of causes.

A quick “by hand” example:

package main

import (
	"errors"
	"fmt"
)

func main() {
	err := errors.Join(
		errors.New("missing title"),
		errors.New("priority must be 1..5"),
	)
	fmt.Println(err.Error())
	// missing title
	// priority must be 1..5
}

This is already better than a single string with ;, but it’s still not ideal UX, because in a real program you’ll get “operations” (Op), nested errors, and the format can become unpredictable. We want our own controlled format: “summary + bullets.”

Stable item order (so the output is predictable)

Stability matters. Especially in a CLI, where output may be read not only by people, but also by tests/scripts. If the item order jumps around, it feels like the program “has a mind of its own.”

The simplest way to get stability is to collect errors into a []error in a predefined order of checks. Not via a map, not “however it happens,” but sequentially: title → priority → due.

Suppose add accepts three parameters: title, priority, due.

package tasker

import (
	"errors"
	"strconv"
	"time"
)

func validateAddArgs(title, priorityRaw, dueRaw string) error {
	var problems []error

	if title == "" {
		problems = append(problems, errors.New("title is required"))
	}
	if _, err := strconv.Atoi(priorityRaw); err != nil {
		problems = append(problems, errors.New("priority must be integer"))
	}
	if _, err := time.Parse("2006-01-02", dueRaw); err != nil {
		problems = append(problems, errors.New("due must be YYYY-MM-DD"))
	}

	if len(problems) == 0 {
		return nil
	}
	return errors.Join(problems...)
}

Modern Go behaves such that errors.Join() returns nil for an empty set, but in teaching code it’s useful to write explicitly if len(problems) == 0 { return nil }, so the logic reads like a simple instruction: “if there are no problems, there’s no error.”

4. How to “unpack” a join error into a list of causes

To print a multi-error nicely, we need a list of causes. At the same time, we don’t want to depend on the internal implementation of errors.Join.

In Go, this is solved via an “optional interface”: a join error supports Unwrap() []error.

We’ll add a small helper in a package (for example, internal/apperr or just apperr, as you’ve already done for Kind/AppError):

package apperr

type joiner interface {
	Unwrap() []error
}

func UnwrapMany(err error) []error {
	if err == nil {
		return nil
	}
	j, ok := err.(joiner)
	if !ok {
		return []error{err}
	}
	return j.Unwrap()
}

Notice: there is no errors.As here, and no coupling to standard library types. We simply check: “can this error unwrap into a list?” If not, we treat it as a regular single error.

5. The “summary + bullets” format for stderr

Let’s agree on a format that usually works well.

The first line is a short summary, for example:

invalid input:

Then items, one per line:

- title is required
- priority must be integer
- due must be YYYY-MM-DD

This scans well, doesn’t turn into a novel, and the person can quickly fix everything in a single run.

Let’s write a FormatProblems function that takes an error (regular or joined) and returns a string for stderr:

package apperr

import (
	"strings"
)

func FormatProblems(head string, err error) string {
	items := UnwrapMany(err)
	if len(items) == 0 {
		return ""
	}
	if len(items) == 1 {
		return head + ": " + items[0].Error()
	}

	var b strings.Builder
	b.WriteString(head)
	b.WriteString(":\n")

	for i := 0; i < len(items); i++ {
		b.WriteString("- ")
		b.WriteString(items[i].Error())
		if i != len(items)-1 {
			b.WriteString("\n")
		}
	}
	return b.String()
}

The details here matter—what can feel like “nitpicking” makes UX better: ":\n" is added only when there are multiple items, there’s no extra trailing newline, and the format remains stable and predictable.

6. Integration with the error contract: AppError and KindValidation

It’s important not to mix up the roles.

errors.Join and the list of causes are the underlying cause of the error.

AppError.Kind is the error class (validation/io/internal).

AppError.Op is the operation context (for logs/diagnostics).

A user-facing message is a separate product that we generate at the CLI boundary.

For example, inside the add command you can do this (simplified, no flags—just the idea):

package tasker

import (
	"example/apperr"
)

func Add(title, priorityRaw, dueRaw string) error {
	if err := validateAddArgs(title, priorityRaw, dueRaw); err != nil {
		return &apperr.AppError{
			Kind: apperr.KindValidation,
			Op:   "tasker add",
			Err:  err,
		}
	}
	return nil
}

Then at the boundary (where we print to the user) we do logic roughly like this: if KindValidation, print "invalid input" and unwrap the causes.

Why? Because if you start printing inside validateAddArgs, you smear responsibility across layers. In Go it’s idiomatic to keep printing and process termination at the top level, while lower layers return errors as values.

7. Output examples and flow diagram

Table: what the output should look like

To avoid keeping everything in your head, it’s useful to lock down a mini-table of “what to print.”

Scenario What the code returned What we show the user
No errors
err == nil
Nothing (or the command result in stdout)
One issue a regular
error
invalid input: <message>
Multiple issues
errors.Join(...)
invalid input:
and then bullet items
- ...

The point is that the format is predictable: from the very first line, the user understands it’s an “input error,” and then they get a checklist.

Mini flow diagram: from argument checks to stderr

Sometimes a diagram helps so you don’t mix up what happens where:

flowchart TD
    A[CLI command received args] --> B[validate... collects problems]
    B -->|0 problems| C[return nil]
    B -->|1+ problems| D["errors.Join(problems...)"]
    D --> E[wrap into AppError KindValidation]
    E --> F[CLI boundary: UserMessage/FormatProblems]
    F --> G[stderr: summary + bullets]

If this flow is enforced, you usually eliminate 80% of “why is this printed twice?” and “why does the format jump around?”—because printing is centralized.

8. Common mistakes when printing multi-error in a CLI

Mistake #1: printing the joined error as err.Error() and hoping “it’s fine as-is.”
errors.Join does build a string, but that’s not your UX contract. Today it’s a newline, tomorrow you wrap the error one more level and get “operation: err1\nerr2,” and the day after tomorrow you add Op and the “wall of text” begins. It’s much more reliable to unwrap causes and print bullets in your stable format.

Mistake #2: calling errors.Join(problems...) even when there are no problems, and not thinking about code readability.
Modern Go will neatly return nil, but the code reader may not know that detail (or may simply forget). In teaching code, it’s better to write explicitly if len(problems) == 0 { return nil }, so the logic reads like plain English: “if there are no problems, there’s no error.”

Mistake #3: collecting errors in a map and getting a random item order.
This is especially sneaky: you might not notice the problem at all until one day a test fails because the lines swapped places. For validation, it’s better to keep the order of checks fixed and append errors to a slice in order; and if you really want a map (for example, by field), then sort the keys at print time.

Mistake #4: mixing user text and diagnostic context in one message.
If you start printing something like "tasker add: validateAddArgs: strconv.Atoi: invalid syntax", the person has to act like a compiler. The user needs wording at the level of "priority must be integer", while the cause chain should remain in the error for logs. The idea of wrapping is exactly that context is preserved for diagnostics, but does not have to be “spilled” to the user in full.

Mistake #5: doing multi-error “everywhere,” including I/O and internal errors.
Multi-error is a powerful tool, but mainly for the “fix the input” scenario. If you try to collect a bunch of I/O issues, you often get weird behavior: the program kept going after the first critical failure just to “collect stats,” and the user got a list that doesn’t make it clear what to do. For I/O it’s usually better to “fail fast,” while for validation it’s “show everything at once.”

1
Task
Go SELF, level 55, lesson 3
Locked
List of causes
List of causes
1
Task
Go SELF, level 55, lesson 3
Locked
Polite validation
Polite validation
1
Task
Go SELF, level 55, lesson 3
Locked
Human-readable report
Human-readable report
1
Task
Go SELF, level 55, lesson 3
Locked
Add command
Add command
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION