The Functional Options Pattern in Go

The functional options pattern in Go provides an easy way to pass optional configuration values to a constructor.

Get the code for this pattern on GitHub 👉 functional-options.go

Type Definitions 📃

Let’s define an example type: an HTTP client. It has unexported fields because we don’t want them to be changed without our knowing. We’ll also define an Option type that accepts our client as its only parameter.

type Client struct {
	baseUrl          string
	baseClient       *http.Client
	disableRedirects bool
}

type Option func (*Client)

Constructor and Default Values 🔧

To initialize the client with our options, we need a constructor. Note how the options—which are simply functions—are applied to the constructed instance.

Options are meant to be optional: this is why we specify them with a variadic constructor rather than a fixed number of arguments. We should set reasonable defaults if the zero values of the fields aren’t enough. In this case, we set a default base client, but we’re fine leaving disableRedirects at false.

func New(opt ...Option) *Client {
    c := new(Client)
    for _, o := range opt {
        o(c)
    }

    // Set a default client if one was not provided
    if c.baseClient == nil {
        c.baseClient = http.DefaultClient
    }

    return c
}

Options ✅

How do we define the options themselves? Return a closure that carries the value to be set. By convention, the option names start with With. Choose a name that is clear and concise. The name may even indicate the default behavior if the option weren’t specified: for example, WithoutRedirects implies that when not specified, the client will follow redirects by default.

func WithBaseUrl(url string) Option {
    return func(c *Client) {
        c.baseUrl = url
    }
}

func WithBaseClient(base *http.Client) Option {
    return func(c *Client) {
        c.baseClient = base
    }
}

func WithoutRedirects() Option {
    return func(c *Client) {
        c.disableRedirects = true
    }
}

Using the Options 👩‍💻

We can now configure our clients, specifying options in any order. Because our constructor provides sensible defaults, we can also leave out the options entirely. Here are a few examples:

var c *metrics.Client

c = metrics.NewClient()
c = metrics.NewClient(metrics.WithBaseUrl("https://example.com"))
c = metrics.NewClient(
    metrics.WithBaseClient(myCustomHttpClient),
    metrics.WithoutRedirects(),
)

When Not to use Functional Options ⛔

No code pattern works in every situation, and forcing a pattern can do more harm than good by overcomplicating things or even misleading readers. While powerful, functional options involves a decent amount of setup: the Option type definition, the loop to apply the options, conditionals to provide defaults, and the options themselves.

Required arguments are poorly suited for the options pattern. The compiler will not catch when an argument is missing. To indicate a missing required argument, the constructor would need to either return an error, panic, or provide another form of error handling. Required arguments should be specified explicitly, as in func New(baseUrl string, opts ...Options). If you have many required arguments, consider that your type may be doing too many things, so revisit the Single Responsibility Principle and redesign your types.

Variadic arguments can be used without the options pattern. Something simple like string concatenation can make good use of variadic functions (func Concat(...string) string), but functions themselves can certainly be passed as arguments, as well:

func Compose(fns ...func(int) int) func(int) int {
	return func(input int) int {
		result := input
		for _, fn := range fns {
			result = fn(result)
		}
		return result
	}
}

Neither of these examples of variadic arguments should be considered the options pattern, nor should they try to conform to the pattern.

Functional Options in the Wild 🐅

Looking for more examples? Check out the code for these projects:

A similar alternative pattern uses method chaining (also called a fluent interface) to specify options on an already constructed object, as seen in the Go Logr API. This offers subtly different behavior from the functional options pattern, such as allowing the object’s configuration to be altered well after initialization.

Will functional options work for your next Golang API, or is it overkill? Give it a try and seek feedback!