Published on

Functional Options are named args on steroids

Functional Options are described as a method of implementing clean/eloquent APIs in Go, but it may be easier to think about them as a powerful replacement for named args from other languages.


For example:

// Go
MyFunc(arg1, arg2, WithNamedArg1(val1), WithNamedArg2(val2))

// Python
my_func(arg1, arg2, named_arg1=val1, named_arg2=val2)

// Ruby
my_func(arg1, arg2, named_arg1: val1, named_arg2: val2)

The implementation looks a bit awkward and verbose, but the extra work gives extra flexibility when compared to classic named args:

type myFuncConfig struct {
	namedArg1 string
	namedArg2 bool
}

type MyFuncOption func(c *myFuncConfig)

func WithNamedArg1(val1 string) {
	return func(c *myFuncConfig) {
		c.namedArg1 = val1
	}
}

func WithNamedArg2(val2 bool) {
	return func(c *myFuncConfig) {
		c.namedArg2 = val2
	}
}

func MyFunc(arg1, arg2 string, opts ...MyFuncOption) {
	c := &myFuncConfig{
		namedArg1: "default value",
		namedArg2: true,
	}
	for _, opt := range opts {
		opt(c)
	}
	fmt.Println(c.namedArg1, c.namedArg2)
}

But why?

Go does not provide many alternatives if you need an extensible API. The usual answer is using a configuration struct so we can add new fields in a backwards compatible manner, but providing default values can be confusing. For example, the following function sets a default timeout on 0 value, but user may also want to use 0 to disable the timeout:

type MyFuncConfig struct {
	Timeout    time.Duration
}

func MyFunc(cfg *MyFuncConfig) {
	if cfg.Timeout == 0 {
		cfg.Timeout = time.Second // default
	}
}

Config structs are also not very flexible, for example, what if we want to accept a directory name as a path to a directory (string), or as a filesystem interface (fs.FS), or via an environment variable. The configuration grows by adding yet another option and with time it becomes harder and harder to figure out how to use it:

type MyFuncConfig struct {
    // Which option should I use?
    Dir    string
    DirFS  fs.FS
    DirEnv string
}

Functional options give a clear answer to both problems:

// Keep this private because it is not part of the API.
type myFuncConfig {
    timeout time.Duration
    dir     fs.FS
}

func WithTimeout(timeout time.Duration) {
    return func(c *myFuncConfig) {
        c.timeout = timeout
    }
}

func WithDir(dir string) {
    return func(c *myFuncConfig) {
        // You could check here if the directory exists
        // or disallow overriding the existing value.
        c.dir = os.DirFS(dir)
    }
}

func WithDirFS(dir fs.FS) {
    return func(c *myFuncConfig) {
        c.dir = dir
    }
}

func DirFromEnv(envName string) {
    return func(c *myFuncConfig) {
        if dir, ok := os.LookupEnv(evnName); ok {
            c.dir = os.DirFS(dir)
        }
    }
}

func MyFunc(opts ...MyFuncOption) {
	c := &myFuncConfig{
        timeout: time.Second,
	}
	for _, opt := range opts {
		opt(c)
	}
    if c.dir == nil {
        panic("dir is required")
    }
}

It is a pleasure to work with such API:

MyFunc(
    WithTimeout(0),    // disable the timeout
    WithDir("/home"),  // use home as default
    DirFromEnv("DIR"), // use env variable if provided
)

Bool options

After getting used to functional options, you may ask youself what is better:

func WithEnabled(on bool) {}

// or

func WithEnabled() {}
func WithDisabled() {}

I recommend using the first variant, because it is more convenient to use:

MyFunc(WithEnabled(env == "prod"))

// vs

var opts []Option
if env == "prod" {
	opts = append(opts, WithEnabled())
} else {
	opts = append(opts, WithDisabled())
}
MyFunc(opts...)

Reusing options for different purposes

You can also use the same options to configure different entities, for example, go-redis could use the same functional options to configure a normal Redis client and a Redis Cluster client by defining a common interface:

type Option func(c configurator)

type configurator interface {
	setTimeout(timeout time.Duration)
}

Then we can use the same options to configure a normal Redis Client:

type Client struct {
	timeout time.Duration
}

func (c *Client) setTimeout(timeout time.Duration) {
	c.timeout = timeout
}

func NewClient(opt ...Option) *Client {
	c := new(Client)
	for _, opt := range opts {
		opt(c)
	}
	return c
}

And a Cluster Client:

type ClusterClient struct {
	timeout time.Duration
}

func (c *ClusterClient) setTimeout(timeout time.Duration) {
	c.timeout = timeout
}

func NewClusterClient(opt ...Option) *Client {
	c := new(ClusterClient)
	for _, opt := range opts {
		opt(c)
	}
	return c
}

Some clients may decide to reject some options by panicking or returning an error. They can even change the semantics of some options, for example, the ClusterClient may decide to accumulate all passed addresses to connect to multiple Redis nodes:

func (c *Client) setAddr(addr string) {
	c.addr = addr
}

func (c *ClusterClient) setAddr(addr string) {
	c.addrs = append(c.addrs, addr)
}

Grouping options

Functional options require creating a lot of configuration functions and godoc automatically groups such functions together under the option name, for example:

type Option func(c *config)
  func WithDir(dir string) Option
  func WithDirFS(dir fs.FS) Option
  func DirFromEnv(envName string) Option

But you can go a step further and create sub-groups for your options using interfaces, for example, uptrace-go client uses functional options to simultaneously configure OpenTelemetry tracing and metrics:

type Option interface {
    apply(c *config)
}

func ConfigureOpentelemetry(opt ...Option) {
    c := new(config)
    for _, opt := range {
        opt.apply(c)
    }
}

We can create separate option groups for tracing and metrics by defining additional interfaces:

type TracingOption interface {
    Option
    tracing() // optional marker
}

type MetricsOption interface {
    Option
    metrics() // optional marker
}

As the result, we now have 3 option groups:

  1. Option applies to tracing and metrics.
  2. TracingOption extends Option and configures tracing.
  3. MetricsOption extends Option and configures metrics.

And godoc will nicely format them:

type Option
  func WithDSN(dsn string) Option
  func WithEnabled(on bool) Option

type TracingOption
  func WithTracingEnabled(on bool) TracingOption

type MetricsOption
  func WithMetricsEnabled(on bool) MetricsOption

Memorable mentions

The first mentions of functional options go back to 2014, but they still seem underutilized in modern Go APIs. Let's change that.