JavaRush /Courses /Go SELF /Error handling in a CLI: details in the error chain and i...

Error handling in a CLI: details in the error chain and in logs

Go SELF
Level 49, Lesson 4
Available

1. Why a “wall of text” in a CLI is bad UX

When we write a console utility, it feels like we can just do fmt.Fprintln(os.Stderr, err) and call it a day. But a CLI has two features: first, people often read it (eyes get tired), and second, scripts often read it (they hate unpredictability). So dumping internal details almost always makes things worse.

Imagine the difference between two messages.

Option A (bad):
error: open /Users/you/.config/tasker/tasks.json: permission denied

Option B (better):
error: cannot read tasks storage

Why is option B better? Because it answers the user’s question: “what should I do?”. For example: check permissions, check the path, run with the right access. The absolute path, the internal file name, the OS error type—those are diagnostics details.

In Go, standard error wrappers often contain internal details (paths, operations, errno), and the Go authors explicitly emphasize: not everything from low-level errors should be “promised” externally as part of stable API behavior. In a CLI this is even more important: error text is your user interface.

We need to learn to separate:

  • a short message for the user (one or two sentences, no technical guts);
  • the technical cause (in a wrapped error, so it can be diagnosed).

2. Two audiences for an error

It helps to think of every error as having two audiences. The first is the user, who wants to understand what to fix. The second is the developer (often it’s you two days later), who wants to understand where it broke, with what data, and what the root cause was. These audiences rarely want the same thing.

If you print everything you know to the user, you get a “wall of text”. If you print only “something went wrong”, you won’t be able to figure it out later. The compromise looks like this: we show a short message outward, and we keep the details in the error chain and reveal them only at the application boundary (in logs, in debug mode, in tests).

In Go, this is a natural style: an error is a value you can “package” and pass upward, adding context. Wrapping works the same way: we preserve the cause, but add context about what was happening around it.

Let’s make a small error type that stores both layers: “what to show the user” and “what to treat as the cause”.

package main

type UserError struct {
	msg string
	err error
}

func (e *UserError) Error() string { return e.msg }
func (e *UserError) Unwrap() error { return e.err }

Here’s the key trick: Error() returns only the short message. And Unwrap() lets you “pull out” the internal cause if you need to analyze it or print it for diagnostics.

3. Print the error once

If an error is printed at every layer (in the command handler, in the dispatcher, in main), the user gets three identical messages and starts suspecting the app is a bit nervous. You need discipline: at the bottom we return error, at the top we print.

Architecturally, it looks like this: all functions like runAdd/runList/runDone return an error, the dispatcher returns an error, and main decides three things: what to print, which exit code to return, and where to call os.Exit.

The skeleton stays calm:

package main

import (
	"fmt"
	"os"
)

func main() {
	err := run(os.Args[1:])
	printUserError(err)
	os.Exit(exitCode(err))
}

func printUserError(err error) {
	if err == nil {
		return
	}
	fmt.Fprintln(os.Stderr, "error:", err)
}

Note: printUserError prints err as a string. But if we agree that our “nice” errors are *UserError, then the string will be exactly the short msg, not the whole technical story.

And one more reminder: os.Exit terminates the process immediately, so any defer after it will not run. Because of that, keep os.Exit only at the very top level, otherwise one day you’ll “lose” a file close or a Flush().

4. Exit codes: usage vs fail

Earlier we agreed on exit codes:

0
/
1
/
2
. Now we need to make this work not “by gut feeling”, but consistently. That is, we should be able to programmatically distinguish “the user invoked the command incorrectly” from “the command was invoked correctly, but something went wrong during execution”.

The simplest approach is a sentinel error ErrUsage and a check via errors.Is. The idea: we don’t parse the error text (it almost always breaks), we ask: “does the cause chain contain ErrUsage or not?”.

package main

import (
	"errors"
)

var ErrUsage = errors.New("usage")

const (
	exitOK    = 0
	exitFail  = 1
	exitUsage = 2
)

func exitCode(err error) int {
	if err == nil {
		return exitOK
	}
	if errors.Is(err, ErrUsage) {
		return exitUsage
	}
	return exitFail
}

Now we just need to learn to create errors so that ErrUsage really ends up in the cause chain, while the user still doesn’t see “internal mush”. For that, we’ll add a couple of small constructors.

package main

import "errors"

func usageError(msg string, cause error) error {
	return &UserError{
		msg: msg,
		err: errors.Join(ErrUsage, cause),
	}
}

func execError(msg string, cause error) error {
	return &UserError{
		msg: msg,
		err: cause,
	}
}

This uses errors.Join: it lets you attach two causes to an error at once (the ErrUsage marker and a concrete cause, e.g., a parsing error). This is convenient because errors.Is(err, ErrUsage) will work, and the cause won’t be lost.

The user will only see msg, because Error() on UserError returns short text.

5. Short messages, but useful

A short message does not mean a “meaningless” one. A good short message usually answers one of these: what exactly was expected, what exactly was received, and what the user can do next. And it almost always fits on one line.

For example, for the done command that expects a numeric id, the message ID must be integer is much more useful than strconv.Atoi: parsing "abc": invalid syntax. The second message is technically more precise, but it’s written for the developer, not the user.

Let’s see how this looks in a command handler using FlagSet. Here we deliberately create two “error branches”: a short one outward and a detailed one inward.

package main

import (
	"flag"
	"fmt"
	"strconv"
)

func runDone(argv []string) error {
	fs := flag.NewFlagSet("done", flag.ContinueOnError)
	if err := fs.Parse(argv); err != nil {
		return usageError("invalid flags", err)
	}
	if fs.NArg() != 1 {
		return usageError("usage: tasker done ID", nil)
	}

	idStr := fs.Arg(0)
	id, err := strconv.Atoi(idStr)
	if err != nil {
		return usageError("ID must be integer", fmt.Errorf("parse ID %q: %w", idStr, err))
	}

	_ = id
	return nil
}

Notice: the “details” are in fmt.Errorf("parse ID %q: %w", ...), and what goes outward is ID must be integer. The user quickly understands what to fix. And if you need to investigate what exactly came in, you’ve already preserved idStr in the cause.

In short CLI messages it’s useful to be a bit less “literary” and a bit more “instructional”. A console is not a novel; it’s normal to speak plainly.

6. Edge case: “task not found”

There is one interesting edge case that almost always sparks debate. Suppose the user runs tasker done 999, but there is no task with that ID. Is that a usage error or an execution error?

If the user runs tasker done abc, that’s definitely usage: the argument type is wrong. But “no such task” is no longer invocation syntax; it’s data state. The command is invoked correctly, flags and arguments are valid, but the action cannot be performed. Therefore it’s logical to treat this as an execution error and return exit code

1
.

To make this feel consistent, it helps to keep this table in mind:

Situation What we tell the user Which exit code
No such command / flags failed to parse / wrong number of arguments usage: ... or a short missing command
2
Wrong argument type (ID is not a number) ID must be integer
2
No data (task not found) task not found
1
Internal failure (e.g., storage read, unexpected nil, I/O) cannot load tasks
1

This is not the only possible choice, but it’s very practical:

2
means “restart it correctly”,
1
means “restarting may not help; the problem is in execution”.

Let’s add a minimal “not found” error with a cause to our training “tasker”.

package main

import "errors"

var ErrNotFound = errors.New("not found")

func markDone(id int) error {
	// suppose internally we search for a task by id
	found := false
	if !found {
		return execError("task not found", ErrNotFound)
	}
	return nil
}

The user will see “task not found”. And you’ll be able to check in code (or diagnostics) errors.Is(err, ErrNotFound) and, for example, distinguish “not found” from an “internal failure”.

7. Diagnostics without “walls of text”

Extracting the cause chain without a logger

We haven’t wired up logging as a full subsystem yet (and that’s fine), but sometimes you want to see what’s inside an error, especially when you’re debugging yourself. The key is not to make this the default behavior, otherwise the user gets a “wall of text” again.

Let’s make a simple diagnostic printer that prints the cause chain. You can temporarily enable it during debugging or keep it disabled in release builds.

package main

import (
	"errors"
	"fmt"
	"os"
)

func printDebugChain(err error) {
	for i := 0; err != nil; i++ {
		fmt.Fprintf(os.Stderr, "debug cause #%d: %v\n", i, err)
		err = errors.Unwrap(err)
	}
}

If your error is *UserError, then errors.Unwrap will go inside via Unwrap() and extract the technical cause. If the cause is further wrapped via %w, the chain continues.

In main you can use it carefully: first print the short message for the user, and then, for example, temporarily call debug printing.

package main

func main() {
	err := run(os.Args[1:])
	printUserError(err)

	if err != nil {
		// Temporary for development. In release builds it is usually disabled.
		printDebugChain(err)
	}

	os.Exit(exitCode(err))
}

Yes, it’s “rough”. But it’s honest: the user doesn’t drown in details, and you don’t lose the cause. In a real application, the role of printDebugChain is usually performed by logs at the application boundary, but conceptually it’s the same thing: details go to diagnostics, not in the user’s face.

Mini example: flag.FlagSet without double printing

By default, flag.FlagSet likes to print its own messages, and if you don’t control the output, you’ll get “double printing”: first flag prints something, then you print error: .... Earlier we chose flag.ContinueOnError so the parser returns an error instead of terminating the process, but it’s still better to direct output where you want it.

The idea is: FlagSet writes diagnostic text to stderr, and we turn that into a short message.

package main

import (
	"flag"
	"os"
)

func runAdd(argv []string) error {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)

	title := fs.String("title", "", "task title")
	if err := fs.Parse(argv); err != nil {
		return usageError("invalid flags", err)
	}
	if *title == "" {
		return usageError("missing -title", nil)
	}
	return nil
}

What matters: even if err contains fairly detailed text from flag, we don’t show it to the user directly as the “primary” message. The primary message is ours—short. And err stays inside as the cause.

8. Common mistakes

Mistake #1: printing the error at every layer (“just in case”).
This usually starts innocently: “I caught an error here; I’ll print it so I can see it.” Then another layer prints too, then a third—and in the end the user sees two or three identical error: lines in a row. The fix is discipline: at the bottom only return err; printing happens once at the application boundary.

Mistake #2: classifying a usage error by the error text.
It’s tempting to write something like strings.Contains(err.Error(), "usage") because it’s “quick”. But it breaks with the smallest text refactor, with localization, and basically with a breeze in the next office. Much more reliable is a sentinel error ErrUsage and an errors.Is check.

Mistake #3: showing the user technical details instead of a message.
In Go, fmt.Errorf("...: %w", err) makes the error string become “...: <cause text>”. If you print that error to the user as-is, you again get a “wall of text” from internal details. The solution is to separate msg (outward) and cause (inward), for example via UserError.

Mistake #4: calling os.Exit inside a command handler.
It seems convenient: “oops, error—exit immediately.” But then you cut yourself off from centralized printing and error classification and, most importantly, you break defer (it won’t run). So the command handler returns error, the dispatcher returns error, and only main calls os.Exit.

Mistake #5: making the message so short that it stops being useful.
The message error happened is indeed short, but it’s useless. A good short text still has to guide: missing command, usage: tasker done ID, ID must be integer, task not found. Short does not mean empty; short means without unnecessary internal details.

1
Task
Go SELF, level 49, lesson 4
Locked
Exit code map
Exit code map
1
Task
Go SELF, level 49, lesson 4
Locked
One error line
One error line
1
Task
Go SELF, level 49, lesson 4
Locked
User-facing error
User-facing error
1
Task
Go SELF, level 49, lesson 4
Locked
Catalog with debug
Catalog with debug
1
Survey/quiz
Subcommands via flag.FlagSet, 49 level, 4 lesson
Unavailable
Subcommands via flag.FlagSet
Subcommands via flag.FlagSet
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION