Advanced flag parsing in standard Go
Table of Contents
Over time, a plethora of libraries were developed to build complex CLI
applications; spf13/cobra and urfave/cli being probably the most used ones
(both have tens of thousands stars on GitHub). While they offer lots of features
out of the box, I often found them complex and overkill for my needs.
In this post, we will have an overview of how Go’s flag works and see some
advanced uses of it. We will then try to implement parsing of complex values
such as comma-separated arguments or slices.
The basics
The flag package implements flag sets and flags. It can look a bit overwhelming
but the basic usage generally boils down to declaring flags and calling a parsing
function:
func main() {
path := flag.String("config", "", "configuration file `path`")
flag.Parse()
fmt.Println(*path)
}
The function above defines a config flag that will store the parsed value in a
variable pointed by path. Note the backticks around `path` in the usage
string; it will be used as a placeholder when printing Usage:
$ go run main.go -h
Usage:
-config path
configuration file path
Flags may be defined by calling functions named after the type of data that needs to be parsed. Basic types are available:
// These functions defines a named flag with a default value and usage string.
// The returned value is the address of a variable storing the parsed value.
func (f *FlagSet) Bool(name string, value bool, usage string) *bool
func (f *FlagSet) String(name string, value string, usage string) *string
func (f *FlagSet) Int(name string, value int, usage string) *int
These also have a Var variant that will store the value in a pre-declared
variable. An usage example would be the configuration of a package variable:
package mypkg
var Foo = "foo"
func main() {
flag.StringVar(&mypkg.Foo, "foo", mypkg.Foo, "configure mypkg.Foo")
flag.Parse()
}
$ go run main.go -h
Usage:
-foo string
configure mypkg.Foo (default "foo")
Finally, the Func flag will call a function with the flag value as a parameter.
It however does not allow setting a default value.
The BoolFunc flag works similarly, but does not require a value to use the flag.
The function will then be called as if the flag was set as -name=true.
func main() {
flag.BoolFunc("version", "print version and exit", func(string) error {
fmt.Println("x.y.z")
os.Exit(0)
return nil
})
flag.Parse()
}
Parsing custom types
The package also provides ways to parse more complex objects.
Using Func
This may be done using the Func flag:
func main() {
var person struct{ firstname, lastname string }
flag.Func("person", "configure person (`firstname:lastname`)", func(s string) error {
var cut bool
person.firstname, person.lastname, cut = strings.Cut(s, ":")
if !cut {
return errors.New("invalid format")
}
return nil
})
flag.Parse()
}
This can feel quite hacky/messy, so I would not use this unless the configured object is limited in scope and has a simple parsing function. And this does not fully support default values as well.
Using the Value interface
Custom implementation
In our example, the proper way would be to define a person type implementing
the flag.Value interface and use the flag.Var function:
type person struct{ firstname, lastname string }
// String implements [flag.Value] and [fmt.Stringer]
// This method requires a proper implementation, otherwise the [flag]
// package may fail to determine whether a default value was set
func (p *person) String() string {
if p == nil { // The [flag] package may call the method on a zero-valued receiver
return ":"
}
return fmt.Sprintf("%s:%s", p.firstname, p.lastname)
}
// Set implements [flag.Value]
func (p *person) Set(s string) (err error) {
var cut bool
p.firstname, p.lastname, cut = strings.Cut(s, ":")
if !cut {
err = errors.New("invalid format")
}
return
}
func main() {
person := person{"john", "doe"}
flag.Var(&person, "person", "configure person (`firstname:lastname`)")
flag.Parse()
}
Generic stringer implementation
We can leverage fmt.Stringer to create a generic value allowing us to use any
type implementing this interface as a flag value.
type stringerValue[T fmt.Stringer] struct {
p *T
parse func(string) (T, error)
}
func (s stringerValue[T]) String() string {
if s.p == nil {
return ""
}
return (*s.p).String()
}
func (s stringerValue[T]) Set(value string) error {
parsed, err := s.parse(value)
if err != nil {
return err
}
*s.p = parsed
return nil
}
func StringerValue[T fmt.Stringer](p *T, parse func(string) (T, error)) flag.Value {
return stringerValue[T]{p, parse}
}
func main() {
u := &url.URL{}
a := &mail.Address{Address: "foo@example.com"}
ip := netip.MustParseAddr("127.0.0.1")
flag.Var(StringerValue(&u, url.Parse), "url", "")
flag.Var(StringerValue(&a, mail.ParseAddress), "mail", "")
flag.Var(StringerValue(&ip, netip.ParseAddr), "ip", "")
flag.Parse()
fmt.Println(u, a, ip)
}
Environment configuration
It may not be obvious, but the standard library provides enough tools to parse environment variables along with command line flags, a common feature in CLI libraries.
The following example parses environment variables to override default command line flag values, using a mapping function to convert flag names to environment variable names.
func main() {
path := flag.String("config", "", "configuration file `path`")
flag.VisitAll(func(f *flag.Flag) {
// Map every command line flag to the corresponding environment variable
env := f.Name
env = strings.NewReplacer(".", "_", "-", "").Replace(env)
env = strings.ToUpper(env)
f.Value.Set(os.Getenv(env))
})
flag.Parse()
fmt.Println(*path)
}