DEV Community

Cover image for Functional options on steroids
Márk Sági-Kazár
Márk Sági-Kazár

Posted on • Originally published at sagikazarmark.hu

Functional options on steroids

Originally published at https://sagikazarmark.hu.


Functional options is a paradigm in Go for clean and extensible APIs popularized by Dave Cheney and Rob Pike. This post is about the practices that appeared around the pattern since it was first introduced.

Update (2022-06-26): Improved Option type


Functional options came to life as a way to create nice and clean APIs with configuration, specifically involving optional settings. There are many obvious ways to do it (constructor variants, config struct, setter methods, etc), but they fall short when a package has a dozen options and don't produce nearly as nice APIs as functional options do.

Recap - What are functional options?

Normally, when you construct an "object", you do that by calling a constructor and passing the necessary arguments to it:

obj := New(arg1, arg2)
Enter fullscreen mode Exit fullscreen mode

(Let's ignore the fact that there are no traditional constructors in Go for a moment.)

Functional options allow extending the API with optional parameters, turning the above line into this:

// I can still do this...
obj := New(arg1, arg2)

// ...but this works too
obj := New(arg1, arg2, myOption1, myOption2)
Enter fullscreen mode Exit fullscreen mode

Functional options are basically variadic function type arguments that accept the constructed (or an intermediary config) type as a parameter. Thanks to the variadic nature, it's perfectly valid to call a constructor with no options at all, keeping it clean even when you want to fall back to defaults.

To better demonstrate the pattern, let's take a look at a realistic example (without functional options first):

type Server struct {
    addr string
}

// NewServer initializes a new Server listening on addr.
func NewServer(addr string) *Server {
    return &Server {
        addr: addr,
    }
}
Enter fullscreen mode Exit fullscreen mode

After adding a timeout option the code looks like this:

type Server struct {
    addr string

    // default: no timeout
    timeout time.Duration
}

// Timeout configures a maximum length of idle connection in Server.
func Timeout(timeout time.Duration) func(*Server) {
    return func(s *Server) {
        s.timeout = timeout
    }
}

// NewServer initializes a new Server listening on addr with optional configuration.
func NewServer(addr string, opts ...func(*Server)) *Server {
    server := &Server {
        addr: addr,
    }

    // apply the list of options to Server
    for _, opt := range opts {
        opt(server)
    }

    return server
}
Enter fullscreen mode Exit fullscreen mode

The resulting API is easy to use and read:

// no optional paramters, use defaults
server := NewServer(":8080")

// configure a timeout in addition to the address
server := NewServer(":8080", Timeout(10 * time.Second))

// configure a timeout and TLS in addition to the address
server := NewServer(":8080", Timeout(10 * time.Second), TLS(&TLSConfig{}))
Enter fullscreen mode Exit fullscreen mode

In comparison, here is how constructor variants and a config struct version look like:

// constructor variants
server := NewServer(":8080")
server := NewServerWithTimeout(":8080", 10 * time.Second)
server := NewServerWithTimeoutAndTLS(":8080", 10 * time.Second, &TLSConfig{})


// config struct
server := NewServer(":8080", Config{})
server := NewServer(":8080", Config{ Timeout: 10 * time.Second })
server := NewServer(":8080", Config{ Timeout: 10 * time.Second, TLS: &TLSConfig{} })
Enter fullscreen mode Exit fullscreen mode

The advantage of using functional options over constructor variants is probably obvious: they are easier to maintain and read/write. Functional options also beat config struct when there are no options passed to the constructor (empty struct),
but in the following sections I will show more examples where a config struct may fall short.


Read the full story of functional options by following the links in the post introduction.

Functional option practices

Functional options themselves are nothing more than functions passed to a constructor. The simplicity of just using plain functions gives flexibility and a lot of potential. Because of that, it's no surprise that quite a few practices emerged around the pattern over the years. Here is a list of what I consider the most popular and useful practices.

Feel free to leave a comment if you think something is missing.

Option type

The first thing you might want to do when applying the functional options pattern is defining a type for the option function:

// Option configures a Server.
type Option func(s *Server)
Enter fullscreen mode Exit fullscreen mode

While this may not seem like a huge improvement, it actually makes the code more readable by using a type name instead of a function definition:

func Timeout(timeout time.Duration) func(*Server) { /*...*/ }

// reads: a new server accepts an address
//      and a set of functions that accepts the server itself
func NewServer(addr string, opts ...func(s *Server)) *Server

// VS

func Timeout(timeout time.Duration) Option { /*...*/ }

// reads: a new server accepts an address and a set of options
func NewServer(addr string, opts ...Option) *Server
Enter fullscreen mode Exit fullscreen mode

Another advantage of having an option type is that Godoc organizes the option functions under the type:

Option type index

A better Option type

Unfortunately, the Option type above introduces a serious flaw into the API: it allows changing the configuration after initialization, thanks to the exported function:

server := NewServer(":8080", Timeout(10 * time.Second))
Timeout(999 * time.Hour)(server) // potential race condition
Enter fullscreen mode Exit fullscreen mode

There are several ways to solve this problem without losing any of the advantages of the Option type.

The easiest one is declaring the option function as a non-exported type first:

// option configures a Server...
type option func(s *Server)

// ...but the outside world doesn't know that.
type Option option
Enter fullscreen mode Exit fullscreen mode

Another solution is using a non-exported type for the receiver:

type serverOptions struct { /* ... */ }

// Option configures a Server.
type Option func(o *serverOptions)
Enter fullscreen mode Exit fullscreen mode

This is obviously a bit more work, but it also lets you deal with defaults more elegantly:

type serverOptions struct {
    timeout time.Duration
}

// call this function when initializing Server.
func (o serverOptions) getTimeout() {
    if o.timeout == 0 {
        return 10 * time.Second // default timeout is 10 seconds
    }

    return o.timeout
}
Enter fullscreen mode Exit fullscreen mode

The third (and most advanced) solution is making the Option type an interface:

// Option configures a Server.
type Option interface {
    apply(s *Server)
}
Enter fullscreen mode Exit fullscreen mode

Read on to learn more about this solution.

Option list type

Usually, functional options are used for creating a single instance of something, but that's not always the case. Reusing a list of default options when creating multiple instances is not uncommon either:

defaultOptions := []Option{Timeout(5 * time.Second)}

server1 := NewServer(":8080", append(defaultOptions, MaxConnections(10))...)

server2 := NewServer(":8080", append(defaultOptions, RateLimit(10, time.Minute))...)

server3 := NewServer(":8080", append(defaultOptions, Timeout(10 * time.Second))...)
Enter fullscreen mode Exit fullscreen mode

That's not quite readable code though and the point of using functional options is having friendly APIs. Luckily, there is a way to simplify it. We just have to make the []Option slice an Option itself:

// Options turns a list of Option instances into an Option.
func Options(opts ...Option) Option {
    return func(s *Server) {
        for _, opt := range opts {
            opt(s)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

After replacing the slice with the Options function the above code becomes:

defaultOptions := Options(Timeout(5 * time.Second))

server1 := NewServer(":8080", defaultOptions, MaxConnections(10))

server2 := NewServer(":8080", defaultOptions, RateLimit(10, time.Minute))

server3 := NewServer(":8080", defaultOptions, Timeout(10 * time.Second))
Enter fullscreen mode Exit fullscreen mode

With / Set option name prefix

Options are often complex types, unlike a timeout or a maximum number of connections. For example, the server package might define a Logger interface as an option (and fall back to a noop logger by default):

type Logger interface {
    Info(msg string)
    Error(msg string)
}
Enter fullscreen mode Exit fullscreen mode

Logger as a name obviously cannot be used for the option as it's already taken by the interface.
LoggerOption could work, but it's not really a friendly name. When you look at the constructor as a sentence though, the word with comes to mind, in our case: WithLogger.

func WithLogger(logger Logger) Option {
    return func(s *Server) {
        s.logger = logger
    }
}

// reads: create a new server that listens on :8080 with a logger
NewServer(":8080", WithLogger(logger))
Enter fullscreen mode Exit fullscreen mode

Another common example of a complex type option is a list (slice) of values:

type Server struct {
    // ...

    whitelistIPs []string
}

func WithWhitelistedIP(ip string) Option {
    return func(s *Server) {
        s.whitelistIPs = append(s.whitelistIPs, ip)
    }
}

NewServer(":8080", WithWhitelistedIP("10.0.0.0/8"), WithWhitelistedIP("172.16.0.0/12"))
Enter fullscreen mode Exit fullscreen mode

In this case, the default behavior is usually append instead of set which aligns with the fact that with rather suggests addition to a list than overwriting it. If you need to overwrite the existing set of values, you can use the set word in the option name:

func SetWhitelistedIP(ip string) Option {
    return func(s *Server) {
        s.whitelistIPs = []string{ip}
    }
}

NewServer(
    ":8080",
    WithWhitelistedIP("10.0.0.0/8"),
    WithWhitelistedIP("172.16.0.0/12"),
    SetWhitelistedIP("192.168.0.0/16"), // overwrites any previous values
)
Enter fullscreen mode Exit fullscreen mode

Similarly, an option for prepending can easily be created if necessary.

The preset pattern

Specific use cases are often generic enough for supporting them in a library. In case of configuration, this could mean a set of options being grouped together and used as a preset for a use case. In our example, Server might have a public and an internal use case that configures timeouts, rate limits, number of connections, etc differently:

// PublicPreset configures a Server for public usage.
func PublicPreset() Option {
    return Options(
        WithTimeout(10 * time.Second),
        MaxConnections(10),
    )
}

// InternalPreset configures a Server for internal usage.
func InternalPreset() Option {
    return Options(
        WithTimeout(20 * time.Second),
        WithWhitelistedIP("10.0.0.0/8"),
    )
}
Enter fullscreen mode Exit fullscreen mode

While presets can be useful in a few cases, they probably have more value in internal libraries and less value in public, generic libraries.

Default values vs default preset

In Go, empty values always have a default. For numbers it's generally zero, for boolean values it's false, and so on. It's considered to be a good practice to rely on default values in optional configuration. For instance, zero value should mean unlimited timeout instead of "no timeout" (which is usually pointless).

In some cases though the zero value is not a good default. For example, the default value of a Logger is nil which would lead to panics (unless you guard log calls with conditional checks).

In those cases setting a value in a constructor (before applying options) is a good way to define a fallback:

func NewServer(addr string, opts ...func(*Server)) *Server {
    server := &Server {
        addr:   addr,
        logger: noopLogger{},
    }

    // apply the list of options to Server
    for _, opt := range opts {
        opt(server)
    }

    return server
}
Enter fullscreen mode Exit fullscreen mode

I've seen examples for having a default preset (using the pattern explained in the previous section). However, I don't consider that a good practice. It's much less expressive than just simply setting default values in the constructor:

func NewServer(addr string, opts ...func(*Server)) *Server {
    server := &Server {
        addr:   addr,
    }

    // what are the defaults?
    opts = append([]Option{DefaultPreset()}, opts...)

    // apply the list of options to Server
    for _, opt := range opts {
        opt(server)
    }

    return server
}
Enter fullscreen mode Exit fullscreen mode

Config struct option

Having a Config struct as a functional option is probably not so common, but it's not without precedent either. The idea is that functional options reference a config struct instead of referencing the actual object being created:

type Config struct {
    Timeout time.Duration
}

type Option func(c *Config)

type Server struct {
    // ...

    config Config
}
Enter fullscreen mode Exit fullscreen mode

This pattern is useful when you have tons of options and creating a config struct seems cleaner than listing all of them in a function call:

config := Config {
    Timeout: 10 * time.Second
    // ...
    // lots of other options
}

NewServer(":8080", WithConfig(config))
Enter fullscreen mode Exit fullscreen mode

Another use case for this pattern is setting defaults:

config := Config {
    Timeout: 10 * time.Second
    // ...
    // lots of other options
}

NewServer(":8080", WithConfig(config), WithTimeout(20 * time.Second))
Enter fullscreen mode Exit fullscreen mode

Advanced patterns

After writing dozens of functional options, you might start to wonder if there is a better way to do it. Not from a consumer point of view, but from the maintainer's perspective.

For example, what if we could define types and use those as options:

type Timeout time.Duration

NewServer(":8080", Timeout(time.Minute))
Enter fullscreen mode Exit fullscreen mode

(Notice how the API from the consumer's point of view remains the same)

It turns out that by changing the Option type we can easily do that:

// Option configures a Server.
type Option interface {
    // apply is unexported,
    // so only the current package can implement this interface.
    apply(s *Server)
}
Enter fullscreen mode Exit fullscreen mode

Redefining the option function as an interface opens the door to a number of new ways to implement functional options:

A variety of builtin types can be used as options without a function wrapper:

// Timeout configures a maximum length of idle connection in Server.
type Timeout time.Duration

func (t Timeout) apply(s *Server) {
    s.timeout = time.Duration(t)
}
Enter fullscreen mode Exit fullscreen mode

Option lists and config structs (seen in the previous sections) can also be redefined like this:

// Options turns a list of Option instances into an Option.
type Options []Option

func (o Options) apply(s *Server) {
    for _, opt := range o {
        o.apply(s)
    }
}

type Config struct {
    Timeout time.Duration
}

func (c Config) apply(s *Server) {
    s.config = c
}
Enter fullscreen mode Exit fullscreen mode

My personal favorite though is the possibility to reuse an option in multiple constructors:

// ServerOption configures a Server.
type ServerOption interface {
    applyServer(s *Server)
}

// ClientOption configures a Client.
type ClientOption interface {
    applyClient(c *Client)
}

// Option configures a Server or a Client.
type Option interface {
    ServerOption
    ClientOption
}


func WithLogger(logger Logger) Option {
    return withLogger{logger}
}

type withLogger struct {
    logger Logger
}

func (o withLogger) applyServer(s *Server) {
    s.logger = o.logger
}

func (o withLogger) applyClient(c *Client) {
    c.logger = o.logger
}

NewServer(":8080", WithLogger(logger))
NewClient("http://localhost:8080", WithLogger(logger))
Enter fullscreen mode Exit fullscreen mode

Summary

Functional options is a powerful pattern for creating clean (and extensible) APIs with dozens of options. Although it's a bit more work than maintaining a simple config struct, it provides a lot more flexibility and produces much cleaner APIs than the alternatives.

Further reading

https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis

https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html

https://www.sohamkamani.com/blog/golang/options-pattern/

https://www.calhoun.io/using-functional-options-instead-of-method-chaining-in-go/

Top comments (0)