Post
TIL: strongly typed CLI flags in Go

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 Set has a pointer receiver (f *LogFormat), this line replaces the value of f with the parsed value:
    *f = LogFormat(s)
    
  • Using strong types doesn’t mean that flag can figure out the valid values; we have to check for them ourselves in Set, and write them into the error message that Set returns.

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:

  1. The value you pass into flag.Var becomes the default value. So setting a default is done by initialising the logFormat variable. 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)
    
  2. Call flag.Var with a pointer to a value that implements Value.

  3. Because flag.Parse calls Set internally on the pointer to logFormat, when we reach this code Set has altered the value of logFormat — meaning we see the parsed value.

    You don’t see code like flag.GetFlagValue("my_flag") when using flag because Set alters your variable directly. Neat!

It feels rather simple, but this approach is exactly how things like time.Duration flags are implemented. Job done.

← Older
Memory ordering of atomics in Rust and Go