Golang Functional Options are named args on steroids
Go Functional Options is 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.
What are functional options?
For example:
// Python
my_func(arg1, arg2, named_arg1=val1, named_arg2=val2)
// Ruby
my_func(arg1, arg2, named_arg1: val1, named_arg2: val2)
// Go
MyFunc(arg1, arg2, WithNamedArg1(val1), WithNamedArg2(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)
}
Why functional options?
Go does not provide many alternatives if you need an extensible API. The usual answer is using a configuration struct so you 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 you 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() {}
Both methods have their merits, but the first one 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 you 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 by categories
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)
}
}
You 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, you now have 3 option groups:
Option
applies to tracing and metrics.TracingOption
extendsOption
and configures tracing.MetricsOption
extendsOption
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
Acknowledgements
The first mentions of functional options go back to 2014, but they still seem underutilized in modern Go APIs. Let's change that.