Today I learned that you can make strongly typed CLI flags using Go’s standard
flag package. flag is a package I’m fond of and I’ve written about it
before. I think the package is
underappreciated and harbours hidden depths.
You make strongly typed flags in Go by implementing the flag.Value interface.
One thing I really liked about this approach is that it gives you a contained
place to validate flag values without polluting main!
Let’s explore the idea by creating a strongly typed flag that selects between pretty logs (for when you are developing) and JSON logs (for production).
The flag.Value interface looks like this:
type Value interface {
String() string // returns the flag's current value as a string
Set(string) error // called by the parser with the raw CLI argument
}
We use this interface to create a strongly typed flag by creating a type alias
and defining flag.Value for it:
package main
import (
"flag"
"fmt"
"log/slog"
"os"
)
type LogFormat string
const (
LogFormatPretty LogFormat = "pretty"
LogFormatJSON LogFormat = "json"
)
func (f *LogFormat) String() string { return string(*f) }
func (f *LogFormat) Set(s string) error {
switch LogFormat(s) {
case LogFormatPretty, LogFormatJSON:
*f = LogFormat(s)
return nil
default:
return fmt.Errorf("unknown log format %q; want pretty|json", s)
}
}
Key interesting bits:
- Because
Sethas a pointer receiver (f *LogFormat), this line replaces the value offwith the parsed value:*f = LogFormat(s) - Using strong types doesn’t mean that
flagcan figure out the valid values; we have to check for them ourselves inSet, and write them into the error message thatSetreturns.
Let’s see how we use it:
func main() {
logFormat := LogFormatJSON // 1
flag.Var(&logFormat, "log-format", "Log output format (pretty|json)") // 2
flag.Parse()
// Use it with the strong type
var handler slog.Handler
switch logFormat { // 3
case LogFormatPretty:
handler = slog.NewTextHandler(os.Stderr, nil)
case LogFormatJSON:
handler = slog.NewJSONHandler(os.Stderr, nil)
}
slog.SetDefault(slog.New(handler))
}
Breaking it down:
The value you pass into
flag.Varbecomes the default value. So setting a default is done by initialising thelogFormatvariable. It’s quite a clean thing. The generated usage text includes your default value:> ./flagtypes -h Usage of ./flagtypes: -log-format value Log output format (pretty|json) (default pretty)Call
flag.Varwith a pointer to a value that implementsValue.Because
flag.ParsecallsSetinternally on the pointer tologFormat, when we reach this codeSethas altered the value oflogFormat— meaning we see the parsed value.You don’t see code like
flag.GetFlagValue("my_flag")when usingflagbecauseSetalters your variable directly. Neat!
It feels rather simple, but this approach is exactly how things like
time.Duration flags are implemented. Job done.