Functional Options

Author(s): David Méthot

Overview

This article offers a quick how-to guide on implementing the functional options pattern in Go.

In Go, function types and values can be passed as arguments to other functions. This feature is the basis for the functional options pattern we're about to explore.

Implementing a Car constructor

First of all, let's define the struct we want to represent our Car. In this simplified case, a Car can have multiple options:

  • A color;
  • Heated seats;
  • A sunroof;

We will also define a basic constructor with default values for our base model.

 1type Car struct {
 2	Color       string
 3	HeatedSeats bool
 4	Sunroof     bool
 5}
 6
 7const (
 8	baseModelColor       = "black"
 9	baseModelHeatedSeats = false
10	baseModelSunroof     = false
11)
12
13
14func NewCar() *Car {
15	return &Car{
16		Color:       baseModelColor,
17		HeatedSeats: baseModelHeatedSeats,
18		Sunroof:     baseModelSunroof,
19	}
20}

Obviously, a client will probably ask for the capability to customize their Car the way they want it. We could then change our constructor to support arguments.

1func NewCar(color string, heatedSeats, sunroof bool) *Car {
2	return &Car{
3		Color:       color,
4		HeatedSeats: heatedSeats,
5		Sunroof:     sunroof,
6	}
7}

Now, what let's assume this awesome library was already released to the public and new feature requests keep being submitted to add more options. We would then find ourselves with a never ending list of constructor arguments.

Another popular way of solving this problem is to simply pass a configuration struct containing each option.

 1type CarConfig struct {
 2	Color string
 3	HeatedSeats bool
 4	Sunroof bool
 5}
 6
 7func NewCar(config CarConfig) *Car {
 8	return &Car{
 9		Color:       config.Color,
10		HeatedSeats: config.HeatedSeats,
11		Sunroof:     config.Sunroof,
12	}
13}

While this is a generally accepted way of writing Go, we could still do better (and we will explain why later). Enter the functional options pattern.

Enhancing our constructor with functional options

Let's start by defining the function type that will receive a Car pointer.

1type CarOption func(*Car)

We can then define some functional option constructors that return functions that will mutate a Car instance.

 1func WithColor(color string) CarOption {
 2	return func(c *Car) {
 3		c.Color = color
 4	}
 5}
 6
 7func WithHeatedSeats() CarOption {
 8	return func(c *Car) {
 9		c.HeatedSeats = true
10	}
11}
12
13func WithSunroof() CarOption {
14	return func(c *Car) {
15		c.Sunroof = true
16	}
17}

Finally, we can enhance our initial constructor to accept a variadic list of CarOption.

 1func NewCarWithOptions(options ...CarOption) *Car {
 2	c := &Car{
 3		Color:       baseModelColor,
 4		HeatedSeats: baseModelHeatedSeats,
 5		Sunroof:     baseModelSunroof,
 6	}
 7
 8	for _, o := range options {
 9		o(c)
10	}
11
12	return c
13}

We can see that before returning the created struct, we iterate through the list of options and call them by passing in a pointer to the created Car.

Ultimately, this is what using our shiny new API would look like:

1c := NewCar(
2    WithSunroof(),
3    WithHeatedSeats(),
4    WithColor("blue"),
5)

Summary

The functional options pattern allows us to write very expressive struct configuration, but does come with the upfront cost of additional code (as opposed to passing everything as function arguments).

Before using this pattern, ask yourself a few questions:

  • Am I expecting my configuration options to evolve over time?
  • Do I need to support default values in certain use cases?
  • Would my configuration benefit from verbosity due to its complex nature?
  • Do I need extra logic on some configuration options?

Answering yes to any number of these questions could mean your code would benefit from functional options. As always, use the right tool for the right job!

I hope this article helped you discover a new approach on struct configuration using the functional options pattern.

Thank you for reading!

Posts in this Series