JavaRush /Courses /Go SELF /Input validation — short messages for the user

Input validation — short messages for the user

Go SELF
Level 50, Lesson 3
Available

1. Why input validation is part of UX

If you’ve ever run a utility and gotten back: panic: something went wrong, you already know why validation matters. A CLI is an interface. And if the interface complains in an incomprehensible way, the user has only two options: either abandon the tool, or start “shamaning” the arguments at random (usually in the spirit of “maybe if I add one more dash…”).

Input validation is the layer that turns a “wrong invocation” into a clear action: what exactly is wrong and how to fix it. In a good CLI this looks almost boring: one line of error, one hint on “how to view help”, and no walls of text, stack traces, or philosophy about the meaning of life.

Parsing ≠ validation: separate “parse” and “understand the meaning”

When we call fs.Parse(argv), we’re solving a purely syntactic task: recognize -title, -priority, separate positional arguments, understand where flags are and where values are. But the “meaning” of the invocation starts after that: is -title required, is it non-empty, is the range of -priority valid, do -done and -undone conflict.

It’s handy to keep this in mind as a small diagram. It helps you avoid mixing logic, and because of that the code becomes easier to read (and harder to break with an accidental edit).

flowchart TD
  A[argv] --> B[Parse flags/args]
  B -->|parse error| E[usage error -> exit 2]
  B --> C[Validate meaning]
  C -->|validation error| E
  C --> D[Execute command]
  D -->|runtime error| F[runtime/internal -> exit 1]
  D -->|ok| G[success -> exit 0]

Main idea: parsing answers “can we read this?”, and validation answers “can we execute this?”.

2. Problem classes: usage and runtime/internal

For the user it’s almost always important to understand: did they make a mistake in how they ran it, or did the program “break along the way”. For us, this distinction is also important because we must return stable exit codes.

Usage error scenarios: missing a required flag, providing an extra argument, specifying conflicting flags, giving a value out of range. Here the program should be brief and to the point, and the exit code should be 2.

Runtime/internal scenarios: for example, the command is invoked correctly, but then something goes wrong (failed to open a file, insufficient permissions, input cannot be read). Here the exit code should be 1. This is an important distinction: don’t “punish” the user with code 2 if they did everything right and our internal part failed.

A small table to “nail to the wall and not forget”:

Problem class Example What we print Exit code
help
todo add -h
command help
0
usage
todo add
1-line reason + usage
2
runtime/internal failed to save data brief for the user; details can be kept in the internal error
1

4. UsageError and short messages for the user

The usageError type instead of analyzing error text

If we just return error and then try to guess from the err.Error() string whether it’s usage or runtime, it becomes brittle. Any change in the text (“missing title” → “title is required”) will suddenly change the exit code. That’s exactly the moment when “it worked yesterday” turns into a legend.

So we make an error type for the usage class. In Go it looks very down-to-earth: a struct with a message and an Error() method. Then we distinguish a usage error by its type.

It’s important to remember that in Go errors are values: we don’t “throw exceptions”, we return values and choose how to react to them. That’s the language style, and it lets you add context and error classification quite neatly.

package main

import "fmt"

type usageError struct {
	msg string
}

func (e usageError) Error() string { return e.msg }

func usagef(format string, args ...any) error {
	return usageError{msg: fmt.Sprintf(format, args...)}
}

Notice that usagef is a small error “factory”. It’s not for prettiness, but for consistency: all usage messages are formed the same way, and you don’t forget about formatting.

Mini rules for UX messages

When we say “a short message”, it doesn’t mean “dry and hostile”. It means “fixable”. And usually it consists of two parts: what’s wrong and what to do next.

Imagine the user: they see an error and want to understand in 3 seconds how to run it correctly. If instead they see 40 lines of “usage”, they’ll scroll and get annoyed. So for invocation errors, a good style is: first one line with the reason, then one line with the “see help” hint (or print the command usage right away, but neatly).

An example of “how it should be” (in the spirit of our future todo):

  • todo add: missing -title
  • try: todo add -h

And here is “how not to do it” — printing the same thing as a panic, or dumping internal details that won’t help the user.

5. Practice: validate todo add and todo list

Validate the add command

Now we’ll build a small but important piece: a function that parses add arguments, validates them, and returns either correct data or a usageError.

Inside the example we use flag.ContinueOnError so that Parse returns an error instead of terminating the process by itself. Also, we’ll disable the “automatic printing” of errors in FlagSet (so there are no duplicates) — for that we’ll route the output to io.Discard.

package main

import (
	"flag"
	"io"
	"strings"
)

type addArgs struct {
	title    string
	priority int
}

func parseAdd(argv []string) (addArgs, error) {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(io.Discard)

	title := fs.String("title", "", "task title (required)")
	priority := fs.Int("priority", 1, "priority 1..5")

	if err := fs.Parse(argv); err != nil {
		return addArgs{}, usagef("%v", err)
	}

	if strings.TrimSpace(*title) == "" {
		return addArgs{}, usagef("missing -title")
	}
	if *priority < 1 || *priority > 5 {
		return addArgs{}, usagef("-priority must be in range 1..5")
	}

	return addArgs{title: *title, priority: *priority}, nil
}

Pay attention to a subtlety: we check strings.TrimSpace, not just *title == "". This saves you from “tricky” values like " " (yes, such users exist; maybe they’re testing our resilience).

One more UX nuance: the messages refer to the specific parameter that’s wrong. missing -title is a thousand times more useful than “invalid input”.

Validate positional arguments

Commands often have positional arguments. In our add we decided that everything must come via flags. That means if after Parse something remains in fs.Args(), it’s extra. Extra means a usage error.

This is a common situation: the user types todo add buy milk, hoping that buy milk becomes the title. But we didn’t agree on that. And a good CLI must not stay silent — it must explain.

Let’s extend parseAdd with one more check:

package main

import "flag"

func parseAdd(argv []string) (addArgs, error) {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	// ... flags, Parse, title/priority checks

	if fs.NArg() != 0 {
		return addArgs{}, usagef("unexpected arguments: %v", fs.Args())
	}

	// ...
	return addArgs{title: *title, priority: *priority}, nil
}

The wording matters here: we don’t say “bad input”. We say “unexpected arguments” and show which ones. The user immediately sees what extra they passed.

Flag conflicts using list as an example

Now let’s take the list command. Suppose it has filtering flags: -done and -undone. The logic is simple: either show only completed, or only not completed, or all. Enabling both at once is pointless: it’s like asking “show only cats and only not cats”.

This is a perfect example of validating the “meaning”.

package main

import (
	"flag"
	"io"
)

type listArgs struct {
	done   bool
	undone bool
}

func parseList(argv []string) (listArgs, error) {
	fs := flag.NewFlagSet("list", flag.ContinueOnError)
	fs.SetOutput(io.Discard)

	done := fs.Bool("done", false, "only done tasks")
	undone := fs.Bool("undone", false, "only undone tasks")

	if err := fs.Parse(argv); err != nil {
		return listArgs{}, usagef("%v", err)
	}
	if *done && *undone {
		return listArgs{}, usagef("-done and -undone cannot be used together")
	}

	return listArgs{done: *done, undone: *undone}, nil
}

This check should happen before executing the command, so we don’t do “half work” and produce a partial result.

6. Unified error output and exit codes

Exit code constants and determining the code from an error

Now let’s assemble this into the common “wrapper” of our CLI. Ideally, a command returns an error, and the top level decides: what to print and what return code to use.

Let’s define small exit code constants and a function that determines the code from an error.

package main

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

func exitCode(err error) int {
	if err == nil {
		return exitOK
	}
	_, ok := err.(usageError)
	if ok {
		return exitUsage
	}
	return exitFail
}

Print errors briefly, with a hint, without duplicates

Now the most UX-important part: how to print. If it’s a usageError, we print one line with the reason and a hint line. We do not automatically print a giant help, because it’s good when the user asked for it (-h/help), not when the user just forgot one flag. But if your course style suggests printing usage on error — that’s also acceptable; just do it consistently.

For now, we’ll do a super-short variant.

package main

import (
	"fmt"
	"os"
)

func printErr(err error) {
	if err == nil {
		return
	}
	switch err.(type) {
	case usageError:
		fmt.Fprintln(os.Stderr, "usage error:", err.Error())
		fmt.Fprintln(os.Stderr, "try: todo <cmd> -h")
	default:
		fmt.Fprintln(os.Stderr, "error:", err.Error())
	}
}

If you’re thinking now: “and isn’t usage error: a bit too wordy?” — fair question. In real life, people often print just the error text without a prefix. But for beginners it’s useful to clearly see the class of the problem: it helps them understand that they need to fix the command invocation.

Glue the dispatcher: todo add ... and todo list ...

Let’s make a simplified dispatcher. It doesn’t store tasks “for real” (we won’t go into storage today), but it will show the correct control flow.

package main

import (
	"fmt"
	"os"
)

func run(argv []string) int {
	if len(argv) == 0 {
		err := usagef("missing command")
		printErr(err)
		return exitCode(err)
	}

	switch argv[0] {
	case "add":
		args, err := parseAdd(argv[1:])
		if err != nil {
			printErr(err)
			return exitCode(err)
		}
		fmt.Println("added:", args.title) // added: Buy milk
		return exitOK

	case "list":
		_, err := parseList(argv[1:])
		if err != nil {
			printErr(err)
			return exitCode(err)
		}
		fmt.Println("list: (not implemented)") // list: (not implemented)
		return exitOK

	default:
		err := usagef("unknown command: %s", argv[0])
		printErr(err)
		return exitCode(err)
	}
}

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

What matters here is that os.Exit remains only in main. This protects you from a very unpleasant effect: os.Exit does not run defer, and if you start calling it “somewhere inside”, sooner or later you’ll forget why a resource isn’t closed or why a final output isn’t printed.

7. Hints in errors: make them fixable

Now the best part: the hint should be specific. If the error is related to add, it makes sense to suggest todo add -h, not the generic todo <cmd> -h. In other words, the place where we create a usage error often knows best what hint to give.

One simple technique is to add a hint field to usageError, which the top level can print.

package main

import "fmt"

type usageError struct {
	msg  string
	hint string
}

func (e usageError) Error() string { return e.msg }

func usageWithHint(msg, hint string) error {
	return usageError{msg: msg, hint: hint}
}

func usagef(format string, args ...any) error {
	return usageError{
		msg:  fmt.Sprintf(format, args...),
		hint: "todo <cmd> -h",
	}
}

Now in parseAdd you can do this:

package main

import (
	"flag"
	"io"
	"strings"
)

func parseAdd(argv []string) (addArgs, error) {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(io.Discard)

	title := fs.String("title", "", "task title (required)")
	_ = fs.Int("priority", 1, "priority 1..5")

	if err := fs.Parse(argv); err != nil {
		return addArgs{}, usageWithHint(err.Error(), "todo add -h")
	}
	if strings.TrimSpace(*title) == "" {
		return addArgs{}, usageWithHint("missing -title", "todo add -h")
	}
	return addArgs{title: *title, priority: 1}, nil
}

And in printErr:

package main

import (
	"fmt"
	"os"
)

func printErr(err error) {
	if err == nil {
		return
	}

	if ue, ok := err.(usageError); ok {
		fmt.Fprintln(os.Stderr, ue.msg)
		fmt.Fprintln(os.Stderr, "try:", ue.hint)
		return
	}

	fmt.Fprintln(os.Stderr, "error:", err.Error())
}

Now the error becomes “self-contained”: it has not only the fact of the problem, but also a hint on where to look.

8. Common mistakes in input validation

Mistake #1: mixing parse errors and validation errors, then printing a mess to the user.
If in one place you parse flags, check meaning, and also print errors right away, it’s easy to get output where the same thing is reported twice and the exit code “floats”. It’s much calmer to first do Parse, then do semantic validation as a separate step, and only in run/main decide what to print and what code to exit with.

Mistake #2: classifying errors by the text of err.Error() instead of by type.
Today the text is “missing -title”, tomorrow it’s “title is required”, and suddenly your code stops distinguishing usage from runtime. In Go, errors are values, and you should use that: a separate usageError type makes the behavior stable, and the exit code predictable.

Mistake #3: messages that are too long, or “walls” of help for every tiny issue.
The user forgot one flag, and you dumped 30 lines of usage and descriptions of all commands — it’s like asking someone “what time is it?” and getting a textbook on astronomy in response. For usage errors, most of the time one line with the reason and one “how to view help” hint is enough. Full help is good when it’s explicitly requested.

Mistake #4: using panic for input errors.
panic is useful for truly exceptional situations, but invocation errors aren’t one of them. If the user messed up the arguments, that’s a normal scenario, and it should end neatly: a message to stderr, exit code 2, no stack trace, and no feeling that the program got offended.

Mistake #5: starting to execute a command before validation is finished.
If you first “did something” and then realized that -priority is out of range, you’ll get partial effects and weird states. Invocation validation must happen before any action. This is exactly the case where “validate first” isn’t pedantry — it’s how you save the user’s data and nerves.

1
Task
Go SELF, level 50, lesson 3
Locked
On-call command
On-call command
1
Task
Go SELF, level 50, lesson 3
Locked
Task submission
Task submission
1
Task
Go SELF, level 50, lesson 3
Locked
Move file
Move file
1
Task
Go SELF, level 50, lesson 3
Locked
Task Manager
Task Manager
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION