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)
(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)
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,
}
}
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
}
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{}))
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{} })
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)
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
Another advantage of having an option type is that Godoc organizes the option functions under the type:
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
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
Another solution is using a non-exported type for the receiver:
type serverOptions struct { /* ... */ }
// Option configures a Server.
type Option func(o *serverOptions)
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
}
The third (and most advanced) solution is making the Option
type an interface:
// Option configures a Server.
type Option interface {
apply(s *Server)
}
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))...)
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)
}
}
}
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))
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)
}
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))
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"))
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
)
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"),
)
}
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
}
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
}
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
}
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))
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))
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))
(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)
}
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)
}
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
}
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))
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)