CodeGym /행동 /Go SELF /flag.FlagSet: separate flags per command

flag.FlagSet: separate flags per command

Go SELF
레벨 49, 레슨 0
사용 가능

1. Why separate flags are needed for subcommands

처음 CLI 프로그램을 작성할 때는 “예제처럼” 만들고 싶어집니다. flag.String(...)으로 flags를 선언하고, flag.Parse()를 호출하면 끝입니다. 실제로 꽤 편리합니다… 딱 하나의 command일 때까지는요. 그런데 프로그램이 하나의 command가 아니라 여러 command로 구성된 app <cmd> ... 형식이 되면 이야기가 달라집니다. 그때부터 작은 비극적 코미디가 시작됩니다.

학습용 애플리케이션 todo를 만든다고 해봅시다. 아직 디스크 저장은 없고, 단지 CLI를 만져보는 용도입니다. 여기에는 두 개의 command가 있습니다: add와 list. add에는 -title flag가 있고, list에는 -done flag가 있습니다.

두 flags를 “global”로 선언하려고 하면 코드는 대략 이렇게 됩니다:

package main

import (
	"flag"
	"fmt"
	"os"
)

func main() {
	title := flag.String("title", "", "task title")
	done := flag.Bool("done", false, "show done tasks only")
	flag.Parse()

	fmt.Println("cmd args:", flag.Args()) // cmd args: [...]
	fmt.Println("title:", *title)         // title: ...
	fmt.Println("done:", *done)           // done: ...
	_ = os.Args
}

문제는 이게 “컴파일이 안 된다”는 것이 아닙니다. 문제는 이것이 의미를 잘못 모델링한다는 점입니다.

todo add -title "Buy milk" command는 굳이 어딘가에 -done flag가 존재하는지 알 필요가 없습니다. 그런데 global parser는 모든 것을 한 번에 알고, 마치 동시에 비행기, 기차, 피자 배달을 관리하려는 공항 관제사처럼 행동하기 시작합니다.

반대로 todo list -done command는 어딘가에 -title이 존재하는지 알 필요가 없습니다. flags를 global로 선언하면, “섞어 쓰는” 방식을 감수하거나, “command가 이거면 저 flags는 무시” 같은 조건문을 잔뜩 쓰게 되고, 결국 스스로 삶을 더 복잡하게 만듭니다.

생각을 고정해두기 위해, 작은 표를 보겠습니다:

Approach What happens Why it is bad with subcommands
Global flags (flag.String, flag.Parse) The whole app shares one common set of flags Flags from different commands get mixed, help per command is harder, and errors are harder to control
flag.FlagSet per command Each command has its own mini parser Commands are isolated, flags do not conflict, and a clean UX is easier to build

2. flag.FlagSet: how it works and how to configure it

What flag.FlagSet is

In plain terms, flag.FlagSet is a separate set of flags plus the logic for parsing them. In other words, it is like “your own little flag”, but not global for the whole app — local to a specific command.

Think of a command as a separate settings tab. The add tab needs one set of settings, the list tab needs another. flag.FlagSet lets you keep those tabs separate.

Creating a FlagSet: NewFlagSet(name, errorHandling)

It is created with flag.NewFlagSet(name, errorHandling):

  • name — the name of the flag set, usually the command name, for example "add".
  • errorHandling — the mode that defines how parsing errors are handled.

Minimal example:

package main

import (
	"flag"
	"fmt"
)

func main() {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fmt.Println(fs.Name()) // add
}

Declaring flags: fs.String, fs.Int, fs.Bool

When you switch to FlagSet, the most important rule is this: inside a command, you no longer use flag.String(...); you use methods of the specific FlagSet: fs.String(...), fs.Int(...), fs.Bool(...).

So the “global flag” is like a shared kitchen. FlagSet is your own small kitchen. And yes, there is less chance of finding someone else’s spoon in your pot.

Example for the add command, which has -title:

package main

import (
	"flag"
	"fmt"
)

func main() {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	title := fs.String("title", "", "task title")

	_ = fs.Parse([]string{"-title", "Buy milk"})
	fmt.Println(*title) // Buy milk
}

Pay attention to the type: fs.String(...) returns *string. This is the same approach as with the global flag.

ContinueOnError: return error instead of “sudden exit”

In CLI development, you want the program to behave predictably. When a user makes a mistake in the arguments, you usually want to:

  • print a short message explaining what is wrong;
  • possibly show the command usage help;
  • exit cleanly.

But if you create a FlagSet in flag.ExitOnError mode (or use the global flag, which by default is closer to “I will solve everything myself”), the parser may print something and terminate execution on its own. And you are left thinking: “Am I actually in charge of this program, or am I just standing here?”

In educational CLIs, it is very useful to choose flag.ContinueOnError: then Parse returns an error instead of exiting the program on its own. That gives you control: you decide what to print, where to print it, and how to respond.

This works well with Go’s “errors are values” approach: we do not use magical exceptions; instead, we explicitly return error and decide at the top level what to do next. When you add context to errors, it is usually done with fmt.Errorf, and to preserve the original cause, you use %w (this is important for proper diagnostics and checks via errors.Is/As).

For now, let us make a simple example: the add command parses flags and returns an error upward.

package main

import (
	"flag"
	"fmt"
)

func runAdd(argv []string) error {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	_ = fs.String("title", "", "task title")

	if err := fs.Parse(argv); err != nil {
		return fmt.Errorf("parse flags for add: %w", err)
	}
	return nil
}

Notice that we do not print anything here. We simply say: “a parsing error occurred inside the command.” The decision of “where to print” is the responsibility of the top level (usually main or a dispatcher). This makes the code much calmer: fewer duplicate messages, less chaos.

SetOutput: where flag prints messages

There is a subtle point: even in ContinueOnError mode, the flag package can print diagnostic messages itself (for example, when it encounters an unknown flag). By default, it may write somewhere you do not expect, and as a result your “useful command output” and “parsing errors” get mixed together.

So a good habit is to explicitly set the FlagSet output to os.Stderr.

package main

import (
	"flag"
	"os"
)

func newAddFlagSet() *flag.FlagSet {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	return fs
}

The idea is simple: everything related to input errors and help goes to stderr. Everything that is the result of the command (task list, created ID, etc.) goes to stdout.

Usage for a specific command

When there are several commands, a “single help page for the whole app” often becomes too verbose. The user runs todo add, makes a mistake, and gets dumped help for list, rm, done, export, and even the secret command summon-the-dragon (which you added when you were in a good mood).

FlagSet lets you assign your own Usage handler: fs.Usage = func() { ... }. This is a function that prints help specifically for this command.

Example: we define Usage for add and print flags via fs.PrintDefaults():

package main

import (
	"flag"
	"fmt"
	"os"
)

func newAddFlagSet() *flag.FlagSet {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)

	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage: todo add -title TEXT")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Flags:")
		fs.PrintDefaults()
	}
	return fs
}

Here the style matters: we print to os.Stderr, because Usage is not “the result of the command”; it is help for the user.

3. The add command: flags → Parse → reading values

Now let us put together a small piece: the function runAdd(argv []string) creates a FlagSet, declares flags, parses arguments, and prints the result.

For now, let us keep the logic as simple as possible: if -title is empty, we return an error (without going deep into a unified CLI error classification, since that is a separate large topic). If everything is fine, we print added: <title>.

package main

import (
	"flag"
	"fmt"
	"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 err
	}
	if *title == "" {
		return fmt.Errorf("missing -title")
	}

	fmt.Fprintln(os.Stdout, "added:", *title) // added: Buy milk
	return nil
}

Notice the order of actions: first declare the flags, then Parse, and only after that read *title. This discipline saves nerves.

4. The list command: a second FlagSet and isolation

To feel the difference, let us add a second command — list — with a -done flag. And importantly: list does not know at all that -title exists. It has its own separate FlagSet.

package main

import (
	"flag"
	"fmt"
	"os"
)

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

	done := fs.Bool("done", false, "show done tasks only")
	if err := fs.Parse(argv); err != nil {
		return err
	}

	fmt.Fprintln(os.Stdout, "list done =", *done) // list done = true
	return nil
}

Now the commands can be run independently:

  • todo add -title "Buy milk"
  • todo list -done

And the flags do not conflict, because each command parses its own argv with a separate parser.

5. The dispatcher: it chooses the command and passes argv

Here is the important idea: the dispatcher, the piece of code that chooses the command, must not parse the command flags. Its job is to understand which command it is (add, list, …) and pass the remaining arguments to the handler.

We will not dig deeply into the dispatcher now (that is a separate topic), but the “world model” is useful. It is convenient to visualize it like this:

flowchart TD
    A[os.Args] --> B[dispatcher: chooses cmd]
    B -->|argv for command| C[runAdd: own FlagSet]
    B -->|argv for command| D[runList: own FlagSet]
    C --> E[stdout/stderr]
    D --> E[stdout/stderr]

The key meaning of the diagram: each command is a small autonomous module with its own flags.

A mini dispatcher sketch, just so it is clear how commands get argv:

package main

import (
	"fmt"
)

func dispatch(cmd string, argv []string) error {
	switch cmd {
	case "add":
		return runAdd(argv)
	case "list":
		return runList(argv)
	default:
		return fmt.Errorf("unknown command: %s", cmd)
	}
}

6. Typical mistakes when working with flag.FlagSet

Mistake #1: using flag.String(...) inside a command instead of fs.String(...).
This happens out of habit: your hand automatically writes flag.String because the internet examples do it that way. As a result, some flags end up in the global parser and some in the local one, and you get strange behavior: flags “sometimes work, sometimes do not”, help is printed by “the wrong command”, and a light panic starts living in your head. The fix is simple: if you are inside runXxx, declare flags via fs.<Type>(...), where fs is your FlagSet.

Mistake #2: creating the FlagSet in a mode that exits the program on its own (ExitOnError).
Sometimes it seems convenient to think “let the library do everything,” but this quickly becomes a problem when you want to manage messages, output, and errors consistently. In a mode where the parser exits by itself, you lose control. With ContinueOnError, you get an error and can organize the application behavior properly, without turning your CLI into a “guess who decides what to print today” attraction.

Mistake #3: not calling fs.SetOutput(os.Stderr) and then wondering “why did help/errors get mixed with the command result”.
This is a subtle but very annoying problem. You want stdout to be redirectable to a file or a pipe, and then suddenly a line about an unknown flag appears there. The reason is usually that the flag output was not explicitly configured. The habit of setting SetOutput right when creating the FlagSet makes behavior predictable.

Mistake #4: reading positional arguments from the original argv instead of from fs.Args() after Parse.
Even if you do not use positional arguments today, you almost certainly will soon. And then the trap appears: after Parse, flags are removed from the argument slice, and the correct source for positional arguments becomes fs.Args(), fs.NArg(), fs.Arg(i). If you keep reading from the original argv, it is easy to get indexes wrong, especially when the user mixes flags and arguments. The right strategy is discipline: first Parse, then everything else.

Mistake #5: the command prints errors itself, and the top level prints them again.
When you are just starting out, it is very tempting to do fmt.Println("error:", err) everywhere. But as soon as the program grows, you get duplicates: the same message is printed twice, sometimes to different streams, sometimes with different prefixes. It is much calmer when the command returns an error, and printing happens in one place only (usually at the top). Then FlagSet with ContinueOnError becomes your ally: it also returns an error without forcing you to print something “from deep inside”.

1
과제
Go SELF, 레벨 49, 레슨 0
잠금
인사 문구
인사 문구
1
과제
Go SELF, 레벨 49, 레슨 0
잠금
터미널에서의 합
터미널에서의 합
1
과제
Go SELF, 레벨 49, 레슨 0
잠금
문구 반복
문구 반복
1
과제
Go SELF, 레벨 49, 레슨 0
잠금
범위 필터
범위 필터
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION