JavaRush /Courses /Go SELF /CLI error unification — locking in the final model

CLI error unification — locking in the final model

Go SELF
Level 55, Lesson 0
Available

1. Why a string is not enough for a CLI contract

When we write our first small console programs, it’s very tempting to do this: “if something went wrong — print err.Error() and quit.” It looks logical and it even works… until the program grows to at least three commands and one file on disk. Then it suddenly turns out that, in a CLI, an error has at least three audiences: the user, who wants a short “what to fix”, the script, which wants an exit code, and the developer, who wants to understand the root cause.

The problem with a string is that a string is “slippery”. Today you wrote return fmt.Errorf("bad id"), tomorrow you changed it to return fmt.Errorf("invalid id"), and suddenly someone’s script that did strings.Contains(err.Error()(), "bad id"), broke. Yes, you shouldn’t do that, but people do — especially we ourselves, if we’re very tired and we really “need it for the release”.

So the key principle of today’s lecture: application logic must not depend on the error text. Text is what humans read with their eyes. Code must make decisions based on structure: the error type, or at least its category.

2. Kind: a minimal error taxonomy for a CLI

To make errors manageable, we need to agree on an error “passport”: which semantic category it belongs to. In Go, people often introduce a field like Kind (or Code, or Class) — but we’ll call it Kind, because the word conflicts less with “exit code” and “HTTP code”.

The idea is simple: Kind is not text. It’s an enum-like value that we’ll later use to decide: which exit code to return, how to log, what to show the user.

Let’s lock in a small set of categories so we don’t create a “48-page encyclopedia of errors”:

Kind Meaning Examples in a CLI
KindValidation
Invalid input / arguments / run configuration didn’t pass -title, -id is not a number
KindNotFound
Resource not found no task exists with that id
KindIO
I/O error failed to read a file, no permissions
KindInternal
Anything unexpected bug, incompatible format, “this should not happen”

Now — code. We want an enum-like definition using iota, so we don’t multiply “magic strings” and typos.

package apperr

type Kind int

const (
	KindInternal Kind = iota
	KindValidation
	KindNotFound
	KindIO
)

Yes, KindInternal is first — on purpose. Let “unknown error” default to internal. In the world of errors it’s like a helmet: maybe not fashionable, but your head stays safer.

3. AppError: a single error shape for the entire application

Now the most important part: we want not just Kind, but a single error type that will be the “official currency” of our application. Let’s call it AppError.

Here it’s important not to overdo it: beginners often make AppError with 12 fields, including “time”, “command”, “user”, “build number”, “moon phase”… and then nobody uses it. We need minimalism that will actually work.

Let AppError contain:

  • Kind — the semantic category;
  • Op — a short operation context (what we were trying to do);
  • Err — the original cause (the internal error), so diagnostics aren’t lost.
package apperr

type AppError struct {
	Kind Kind
	Op   string
	Err  error
}

Now implement Error() string. Important: this is not a user-facing message, it’s just a readable string that’s useful for logs and debugging. Often it’s built like op + ": " + err.

package apperr

func (e *AppError) Error() string {
	if e == nil {
		return "<nil>"
	}
	if e.Op == "" && e.Err != nil {
		return e.Err.Error()
	}
	if e.Err == nil {
		return e.Op
	}
	return e.Op + ": " + e.Err.Error()
}

And finally, the key element: Unwrap() error. It lets Go’s standard tools see the error “cause chain”.

package apperr

func (e *AppError) Unwrap() error {
	if e == nil {
		return nil
	}
	return e.Err
}

Why does this matter right now, even if you “don’t use errors.Is/errors.As yet”? Because you almost certainly will — at least to recognize *AppError at the application boundary. Plus, the standard library and the Go ecosystem in general are already built around the idea of an unwrap chain.

4. Op: technical context, not a user message

When you first see the Op field, your hand reaches to put something like: "Oops, everything broke, please try again". But Op is not UX — it’s technical context: “which operation was being performed when the error happened”.

Imagine you’re fixing a bug from logs. You don’t need “Oops”, you need specifics: "load storage", "parse id", "write output", "read config". It’s like a label on a moving box: “kitchen/plates”, not “something fragile, don’t hit it”.

A good Op is usually:

  • short;
  • stable (doesn’t change every week);
  • safe for logs (no secrets and no personal data).

A bad Op is usually:

  • too long;
  • changes together with the user-facing message text;
  • contains a file path with tokens “to make it convenient” (and then the token leaks into logs...).

5. AppError constructors: one style and less noise

If everywhere we manually write &AppError{Kind: ..., Op: ..., Err: ...}, in a week you’ll see 15 variations of the same thing. Someone will forget Kind, someone will mix up Op and Err, someone will put nil into Err and get weird text.

So let’s make small constructors.

package apperr

func New(kind Kind, op string, cause error) *AppError {
	return &AppError{Kind: kind, Op: op, Err: cause}
}

func Validation(op string, cause error) *AppError {
	return New(KindValidation, op, cause)
}

func NotFound(op string, cause error) *AppError {
	return New(KindNotFound, op, cause)
}

func IO(op string, cause error) *AppError {
	return New(KindIO, op, cause)
}

func Internal(op string, cause error) *AppError {
	return New(KindInternal, op, cause)
}

Now the style is uniform: you see apperr.Validation(...) — it’s immediately clear this is an input error. You see apperr.IO(...) — it’s clear we failed on a file operation.

And most importantly: you stop “inventing an error format” from scratch every time. The program becomes more predictable.

6. How to extract Kind from error: KindOf

Since our functions usually return the error type (an interface), we need a simple way to understand: what error did we get? Is it *AppError or something else?

In Go, for this you use errors.As: it walks the unwrap chain and tries to find an error of the desired type.

package apperr

import "errors"

func KindOf(err error) Kind {
	var ae *AppError
	if errors.As(err, &ae) {
		return ae.Kind
	}
	return KindInternal
}

Note &ae: errors.As takes the address of a variable so it can store the found error of the requested type there. It’s the same principle as fmt.Scan(&x): “here’s the place where you should put the result”.

From a contract point of view this is very convenient: if an error isn’t in our shape, we still treat it as internal (because the application failed to classify it “by the rules”).

7. CLI examples: validation and I/O in one style

So this doesn’t remain “theory about nice structures”, let’s make a small piece of code for a typical CLI: parse a task id from a string. It’s very easy here to make an error that’s “the user’s fault” (validation), not “the program’s fault” (internal).

package todo

import (
	"errors"
	"strconv"

	"example/apperr"
)

func ParseID(raw string) (int, error) {
	if raw == "" {
		return 0, apperr.Validation("parse id", errors.New("id is required"))
	}

	id, err := strconv.Atoi(raw)
	if err != nil {
		return 0, apperr.Validation("parse id", errors.New("id must be integer"))
	}
	return id, nil
}

Here we intentionally don’t “expose” the internals of strconv.Atoi to the user (it will contain text like "invalid syntax"). Validation is where we want to provide a stable and clear error description. And technical details (if needed) can be preserved via wrapping — but that’s the next step in the overall strategy.

Now an I/O example: read the task storage file (say tasks.json or tasks.csv — the format doesn’t matter right now).

package storage

import (
	"os"

	"example/apperr"
)

func Load(path string) ([]byte, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, apperr.IO("read storage file", err)
	}
	return data, nil
}

The idea: the file didn’t read — that’s I/O. Here it’s useful to keep the real cause ("permission denied", "no such file or directory"), because that’s diagnostics, not an “invalid argument input” error.

8. Rules and the error path in a CLI

Why Unwrap is not an optional detail

At a beginner level, it might seem: “Well we already store Err, why do we also need Unwrap()?” And then you’ll want to do two completely ordinary things.

First — recognize errors through the chain. For example, if inside Err you have os.ErrNotExist, you want to handle that cleanly. The standard way is errors.Is, which can walk the unwrap chain.

Second — extract a typed error. For example, you’ll want to find *AppError inside a more general error. For this, errors.As performs the same unwrap-chain traversal.

That’s exactly why Go 1.13 “made official” the Unwrap() convention and added tools for working with cause chains. And %w in fmt.Errorf exists precisely so an error created via formatting does not lose its internal cause and can be correctly “unwrapped”.

Today we’re not building the full wrapping strategy (where, what, and how to wrap) — but we lay the foundation (Unwrap) right away, so we won’t have to do a painful refactor later.

Diagram: how an error moves through layers

To keep the big picture in mind, it helps to mentally see how an error travels through the layers of an application: from bottom to top it “accumulates meaning”, and at the boundary it turns into process behavior.

flowchart TD
    A[Low level: os/strconv/json] -->|err| B[Our code: storage/todo/app]
    B -->|AppError Kind+Op+Err| C[CLI command: subcommand handler]
    C -->|error| D[Application boundary]
    D --> E[stderr: short message]
    D --> F[log: details + context]
    D --> G[exit code: 0/1/2]

Right now we implement only one piece of this diagram: we lock in that “our code” returns errors in a single unified format AppError. Everything else (how to print, how to log, how to compute codes) becomes easier precisely because the “raw material” became uniform.

A strict module rule: what it’s “normal” to return outward

In any project it’s very useful to lock in one rule that you can later verify via code review and tests (and sometimes — simply with your own eyes):

An ideal agreement for the CLI layer sounds like this: application-level functions return error, but inside that error we expect either nil, or *apperr.AppError (possibly wrapped with additional context, but still in a way that lets errors.As extract *AppError).

To “play it safe”, it’s convenient to have a function that turns “anything” into *AppError (at minimum KindInternal). This is especially useful at the command boundary, where you collect the result of the work.

package apperr

import "errors"

func Ensure(err error) *AppError {
	if err == nil {
		return nil
	}
	var ae *AppError
	if errors.As(err, &ae) {
		return ae
	}
	return Internal("unknown error", err)
}

Note: this isn’t “magic”, it’s just a safety net against heterogeneous errors that may come from the standard library or from code that hasn’t yet been migrated to the new style.

9. Common mistakes when unifying CLI errors

Mistake #1: determining the error class via strings.Contains(err.Error()(), "...").
This code looks like a “quick win”, but it breaks from any text change, from localization, from adding context, from wrapping. In the end you get a system where changing wording in one place can change the exit code in another. It’s much more stable to assign Kind once and then work with it.

Mistake #2: turning Kind into a huge catalog with 30 values.
The more categories you create “just in case”, the faster they start to overlap: “is KindConfig validation or internal?”, “is KindPermission IO or validation?”. At the CLI level, a small set like validation/notfound/io/internal is almost always enough, and you should add detail only when it’s actually needed by the contract.

Mistake #3: mixing the user-facing message and technical context in Op.
Op should help debugging and logs, not be “text for stderr”. If you put UX phrasing there, it will start changing, and everything becomes unstable again. For the user, it’s better to build a separate message (later we’ll centralize this at the CLI boundary), and keep a short “operation label” in Op.

Mistake #4: forgetting to implement Unwrap() and thus breaking the cause chain.
Without Unwrap() you deprive yourself of proper errors.Is/errors.As behavior through the chain. This is especially painful when you seemingly wrap everything and add context, but at the boundary you can no longer extract *AppError and correctly classify the error. The unwrap-chain convention in Go appeared specifically to make such scenarios work predictably.

Mistake #5: returning a raw error from the standard library without classification.
os.ReadFile and strconv.Atoi honestly return errors — but they don’t know you have a CLI and that you need exit code 2 for invalid input. If you don’t “lift” the meaning of the error into a Kind, then at the top level you’ll be guessing: did the user make a mistake or did the application? Unification is about not guessing, but knowing.

1
Task
Go SELF, level 55, lesson 0
Locked
Error Passport
Error Passport
1
Task
Go SELF, level 55, lesson 0
Locked
Unified wrapper
Unified wrapper
1
Task
Go SELF, level 55, lesson 0
Locked
Cause detector
Cause detector
1
Task
Go SELF, level 55, lesson 0
Locked
Boundary filter
Boundary filter
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION