JavaRush /Courses /Go SELF /Parsing positional arguments and parsing errors

Parsing positional arguments and parsing errors

Go SELF
Level 49, Lesson 2
Available

1. Positional arguments: what they are and why they matter

While flags like -title="..." look neat and official, positional arguments are often treated as “leftovers”: like, let them exist if you do not feel like making flags. In practice, positional arguments are a normal part of a command-line interface: git commit <message>, mkdir <dir>, go test <pkgs>. The only important thing is that they are parsed strictly and predictably; otherwise the user will have to read your program’s mind (and that is, unfortunately, a paid feature).

Positional arguments are the words in the command line that remain after the flags. For example, in the invocation:

app done -force 12

for the done command, the -force flag is a flag, and 12 is a positional argument (most likely, a task ID).

In our learning app (a simple todo CLI), positional arguments are especially convenient for things like done <id> or rm <id>. It is easier for a person to type a single number than to remember that they need -id=12. But convenience comes with discipline: you must strictly validate the number of arguments and parse types carefully.

2. Argument parsing: Parse → Args/NArg/Arg

Parse first, then Args/NArg/Arg

If you remember one sentence from this lecture, let it be this: read a command’s positional arguments only after fs.Parse(argv) and only via fs.Args()/fs.NArg()/fs.Arg(i). Not via the original argv, not via “well, I can see the second element”, not via magic or intuition. Because the flag package can “eat” flags, shift things around, and leave you with exactly what is truly positional arguments.

The reason is simple: until Parse has run, you do not know what in argv was flags and what was just text. flag parses by its own rules, and your job is to accept the result. Like in a restaurant: first the waiter (Parse) takes the order, and then you (Args) get the dishes. If you start eating “by the list in the menu”, you might accidentally eat the salt shaker.

A mini command handler skeleton looks like this:

package main

import (
	"flag"
)

func runDone(argv []string) error {
	fs := flag.NewFlagSet("done", flag.ContinueOnError)

	if err := fs.Parse(argv); err != nil {
		return err
	}

	// Only now is it safe to read fs.Args()/fs.NArg()/fs.Arg(i)
	return nil
}

Why you should not take positional arguments directly from argv

Sometimes a beginner writes: “my command is done, so argv[0] is the ID”. And in the simple case app done 12, that is true. But as soon as flags appear, things get trickier. For example:

app done -force 12

If you take argv[0], you will get the string -force, not 12. And then you will very sincerely try to parse -force via strconv.Atoi, getting an error that looks like “why is a number not a number”. At that moment, the user starts doubting they can do math, even though the problem is not them.

fs.Parse(argv) is exactly what you need to separate flags from positional arguments and then give you a “clean” list via fs.Args(). So you can think of argv as raw input, and fs.Args() as cleaned and ready to cook.

I will show a small “raw vs cleaned” example:

package main

import (
	"flag"
	"fmt"
)

func demo(argv []string) {
	fs := flag.NewFlagSet("demo", flag.ContinueOnError)
	_ = fs.Bool("force", false, "force mode")

	_ = fs.Parse(argv)

	fmt.Println("raw argv:", argv)       // raw argv: [-force 12]
	fmt.Println("pos args:", fs.Args())  // pos args: [12]
}

3. Tools: Args, NArg, Arg(i)

Once you have called Parse, a FlagSet has three main ways to work with positional arguments. They are similar, but fit different situations. Think of them as three “ways to look at the line”: you can get the length, take an element by index, or get the whole list.

Summary table:

Method What it gives When it is convenient
fs.NArg()
the number of positional arguments when the command requires “exactly N”
fs.Arg(i)
a positional argument by index when you need a specific argument, e.g., an ID
fs.Args()
a slice of all positional arguments when there are many arguments or you want to iterate over them in a loop

In our todo CLI, typical contracts are: done needs exactly one argument, list usually needs zero (we do filters via flags), and for add you sometimes want “everything that is left is the task text”, i.e., Args().

An example of strict validation for “exactly one argument”:

package main

import (
	"errors"
	"flag"
	"fmt"
)

var ErrUsage = errors.New("usage")

func runDone(argv []string) error {
	fs := flag.NewFlagSet("done", flag.ContinueOnError)

	if err := fs.Parse(argv); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	if fs.NArg() != 1 {
		return fmt.Errorf("%w: usage: app done <id>", ErrUsage)
	}
	return nil
}

Here ErrUsage is a marker meaning “the user invoked it incorrectly”. We wrap it via %w, so that later we can recognize the error via errors.Is. The idea of wrapping via %w is that we keep the original cause inside the new error.

4. The positional-argument contract

A CLI command is a small agreement between a person and a program. And positional arguments in that agreement are responsible for two things: count and meaning. When you design a command, you can almost always phrase the rule in words: “the done command requires one argument — the task id, an integer”. That rule must be implemented in code literally, without telepathy.

The good news: implementing this in Go is very easy. The bad news: “easy” often triggers “good enough”, and then the command starts silently ignoring extra arguments. Silence in a CLI is almost always evil: the user thinks everything was applied, while your program just threw half the command into the trash.

Let’s make a small utility to validate the argument count. It will return an ErrUsage error so the classification stays stable.

package main

import (
	"errors"
	"flag"
	"fmt"
)

var ErrUsage = errors.New("usage")

func requireNArgs(fs *flag.FlagSet, n int, usage string) error {
	if fs.NArg() != n {
		return fmt.Errorf("%w: usage: %s", ErrUsage, usage)
	}
	return nil
}

Note: this is not a “library of the century”, but a helper like this makes command handlers much more readable—especially when the number of commands grows.

5. Type parsing and meaningful errors

After you have validated the number of positional arguments, the second layer of the contract begins: types. If we expect a number, we need to convert a string to an int and handle the error correctly. In Go, this is done via strconv.Atoi. The Atoi error itself is useful, but the user often cares more about the context: which argument is broken and what value was provided.

For the done command it looks like this:

package main

import (
	"fmt"
	"strconv"
)

func parseID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("id must be integer, got %q", s)
	}
	return id, nil
}

Here %q prints the string in quotes, which is useful when the user accidentally passed spaces or weird characters. For example, got " 12" immediately hints that there is a space somewhere.

Now let’s combine this with ErrUsage (because “id is not a number” is almost always a usage error):

package main

import (
	"errors"
	"fmt"
)

var ErrUsage = errors.New("usage")

func parseIDUsage(s string) (int, error) {
	id, err := parseID(s)
	if err != nil {
		return 0, fmt.Errorf("%w: %v", ErrUsage, err)
	}
	return id, nil
}

We again use wrapping via %w so that ErrUsage does not get lost in the chain of causes. This matches the general approach: “wrap the error, but do not lose the original cause”.

6. Flag parsing errors and error classification

Positional arguments are half the story. The other half is the errors that fs.Parse(argv) can return. And this is an important part of UX: when a person makes a mistake in flags, that is also a usage error. For example, they typed an unknown flag, forgot a value, or mixed up a type. Such errors should not be disguised as an “internal failure” of the program.

So there is a standard pattern in a command handler: if Parse returns an error, we wrap it in ErrUsage and return it upwards. We do not print it here (the top level will print it), but we classify it. And again—wrapping.

package main

import (
	"errors"
	"flag"
	"fmt"
)

var ErrUsage = errors.New("usage")

func parseFlags(fs *flag.FlagSet, argv []string) error {
	if err := fs.Parse(argv); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	return nil
}

Why not use %w for err here? Because our goal is classification via ErrUsage, not necessarily preserving the entire low-level error structure from flag. In some projects people do use %w (for example, fmt.Errorf("%w: %w", ErrUsage, err)), but then the error has a “double nature”, and you need to think more carefully about what errors.Is will match.

7. End-to-end examples: done and add commands

done command: flags + positional ID

Now we will assemble everything into one coherent piece of code that actually looks like a real command. Let’s imagine we have a done command that accepts the flag -force (a demo flag, just to show how Parse separates flags) and one positional argument <id>.

package main

import (
	"errors"
	"flag"
	"fmt"
	"os"
	"strconv"
)

var ErrUsage = errors.New("usage")

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

	force := fs.Bool("force", false, "mark done even if already done (demo)")

	if err := parseFlags(fs, argv); err != nil {
		return err
	}
	if err := requireNArgs(fs, 1, "app done [flags] <id>"); err != nil {
		return err
	}

	id, err := parseIDUsage(fs.Arg(0))
	if err != nil {
		return err
	}

	fmt.Printf("done: id=%d force=%v\n", id, *force) // done: id=12 force=true
	return nil
}

func parseID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("id must be integer, got %q", s)
	}
	return id, nil
}

What matters here is that we call fs.Arg(0) only after requireNArgs. It is like fastening a seat belt before driving off: you can skip it, but then it will be very painful and very loud.

add command: when there are “many” arguments and you want Args()

The add command usually adds a task with text. A common UX model: everything left after the flags is the task text. That is, we support variants like:

app add "Buy milk"
app add -priority=2 Buy milk today

Here it is more convenient to take fs.Args(), join the words back together, and get the title. This is a good example of why Args() is sometimes more convenient than Arg(0).

package main

import (
	"errors"
	"flag"
	"fmt"
	"strings"
)

var ErrUsage = errors.New("usage")

func runAdd(argv []string) error {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	priority := fs.Int("priority", 1, "task priority (demo)")

	if err := parseFlags(fs, argv); err != nil {
		return err
	}
	if fs.NArg() == 0 {
		return fmt.Errorf("%w: usage: app add [flags] <title...>", ErrUsage)
	}

	title := strings.Join(fs.Args(), " ")
	fmt.Printf("add: %q (priority=%d)\n", title, *priority) // add: "Buy milk today" (priority=2)
	return nil
}

This approach gives a nice UX: the user does not have to use quotes if they do not want to. But the contract still must be strict: if NArg() returns 0, there is no title—and that is a usage error.

8. Mini flow: parsing argv inside a command

Sometimes it is useful to capture what is happening visually, so you stop fighting argv like it is a hydra. The diagram below shows a typical command handler execution flow:

flowchart TD
    A["command argv: []string"] --> B["fs.Parse(argv)"]
    B -->|err != nil| U[return ErrUsage + parse error]
    B -->|ok| C["fs.NArg() / fs.Args()"]
    C -->|contract mismatch| V[return ErrUsage + usage string]
    C -->|match| D[parse types: Atoi / etc]
    D -->|type error| W["return ErrUsage + 'id must be int'"]
    D -->|ok| E[execute the command]
    E --> F[return nil]

The point of this diagram is simple: first we put the input into a clean state (Parse), then we validate the contract (NArg), then we parse types (Atoi), and only then do we perform the work. This is a straightforward path to a CLI that behaves predictably, not like a “random-index lottery”.

9. Common mistakes when parsing positional arguments and types

Mistake #1: reading positional arguments from argv instead of fs.Args().
This kind of code often “works” in examples without flags, and then suddenly breaks when -force (or any other flag) appears. The issue is that argv is raw input, while fs.Args() is the parsed result with flags separated out. So: first Parse, then fs.Args()—and your nervous system will thank you.

Mistake #2: calling fs.Arg(0) without checking fs.NArg().
If the user forgot the argument, the program will reach for Arg(0) and get an empty string, or it will start parsing “nothing” as a number. Best case: a confusing error. Worst case: behavior like “everything succeeded, but why is id=0?”. It is better to validate the argument count first as part of the command contract.

Mistake #3: silently ignoring extra arguments.
The command done 12 extra should either explicitly accept extra as meaningful input (which is weird for done), or honestly say: “too many arguments, usage: …”. Silent ignoring turns a CLI into a guessing game: the user thinks extra was considered, while your program just pretended it did not exist.

Mistake #4: returning a “bare” strconv.Atoi error without context.
If you simply do return strconv.Atoi(fs.Arg(0)), the user will get a message like “invalid syntax” and will guess what exactly is wrong. It is much better to add context: which argument you expected, what value you got, and what it should have been. This is especially useful when a command accepts multiple numbers.

Mistake #5: not distinguishing usage errors from execution errors.
When Parse fails, when there are not enough arguments, when id is not a number—this is almost always a usage error that should be marked as ErrUsage, so the top level can pick the correct exit code and (optionally) display usage. The “errors as values” idea and the practice of wrapping causes via %w help build this kind of classification without text parsing.

1
Task
Go SELF, level 49, lesson 2
Locked
Nickname with a prefix
Nickname with a prefix
1
Task
Go SELF, level 49, lesson 2
Locked
Multiply a parameter
Multiply a parameter
1
Task
Go SELF, level 49, lesson 2
Locked
Join words
Join words
1
Task
Go SELF, level 49, lesson 2
Locked
Two task commands
Two task commands
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION