How to Make the Options Pattern Better

August 1, 2023
How to Make the Options Pattern Better

In our previous post we implemented and talked about benefits of utilising the options pattern to enhance the API of a library we are developing. We explored how this powerful technique can improve user friendliness, flexibility and maintainability.

Today, we are working again on the XYZ company client. We will go through additional strategies that can further improve the way we leverage options pattern in our codebase.

Preventing modification

As a quick recap of the previous post, let’s take a look at how the implementation of client and option looks right now.

    type Option func(*Client)

type Client struct {
	authToken  string
	proxy      string
	maxRetries int
	timeout    time.Duration
}

func New(authToken string, options ...Option) *Client {
	c := &Client{
		authToken:  authToken,
		maxRetries: 3,
	}

	for _, opt := range options {
		opt(c)
	}

	return c
}

func WithProxy(proxy string) Option {
	return func(c *Client) {
		c.proxy = proxy
	}
}
  

Client ’s attributes are not exported and there’s also a constructor. There’s a reason for all of that. Any unexpected modification can put the client into an invalid state or lead to an unexpected behaviour. The biggest flaw of the basic implementation is that it doesn’t grant immutability when it’s desired . Users are able to update previously created client with a simple trick.

    func main() {
	c := client.New("AUTH_TOKEN")
	fmt.Printf("%#v\n", c)
	
	client.MaxRetries(5)(c)
	fmt.Printf("%#v\n", c)
}
  
    $ go run main.go
&client.Client{authToken:"AUTH_TOKEN", maxRetries:3, proxy:"", timeout:0}
&client.Client{authToken:"AUTH_TOKEN", maxRetries:5, proxy:"", timeout:0}
  

All it takes is to pass client to the option function. Our goal is to protect the configuration options by default and allow updates only when it’s safe to do so. How do we achieve that?

Introducing Option interface

There’s another way of defining the option. Instead of the function type we’ll use an interface. Function which operates on the Client and modifies it will now become a method signature.

    type Option interface {
	apply(*Client)
}
  

The constructor will now call the apply method on the option not the option itself.

    func New(authToken string, options ...Option) *Client {
	c := &Client{
		authToken:  authToken,
		maxRetries: 3,
	}

	for _, opt := range options {
-     opt(c)
+     opt.apply(c)
	}

	return c
}
  

Implementing options

The new Option interface can be implemented in different ways. You might not need all of them, but it’s good to have an extra flexibility. Let’s go through some of the popular ones.

The standard way is done by creating a specific structure for the option and implementing the apply method.

    type withProxy struct {
    proxy string
}

func (o withProxy) apply(c *Client) {
    c.proxy = o.proxy
}

func WithProxy(proxy string) Option {
    return withProxy{
        proxy: proxy,
    }
}
  

This approach is very intuitive and simple. However, it generates lots of boilerplate. We can make it more generic so the code can be reused.

    type optionFunc struct {
    fn func(*Client)
}

func (o optionFunc) apply(c *Client) {
    o.fn(c)
}

func newOptionFunc(fn func(*Client)) optionFunc {
    return optionFunc{
        fn: fn,
    }
}

func WithProxy(proxy string) Option {
    return newOptionFunc(func(c *Client) {
        c.Proxy = proxy
    })
}
  

Each option uses the same type. Looking closely at this, notice that this struct is only needed to hold func(*Client) .

A small change can lead us to another (and my personal favourite) implementation.

    type optionFunc func(*Client)

func (fn optionFunc) apply(c *Client) {
    fn(c)
}

func WithProxy(proxy string) Option {
    optionFunc(func(c *Client) {
        c.proxy = proxy
    })
}
  

You might recognise this approach from the net/http package and its HandlerFunc . It’s more concise while having an almost identical usage.

Similarly, you can do the same with any other type you define. However, it brings us really close to the first implementation.

    type proxy string

func (p proxy) apply(c * Client) {
    c.Proxy = string(p)
}

func WithProxy(p string) Option {
    return proxy(p)
}
  

You might notice that each example uses extra functions which return Option . When using more generic approaches it adds proper naming for the options and let’s us add comment documentation for each of them. For others, you might be tempted to skip those functions.

    type Proxy string

func (p Proxy) apply(c *Client) {
    c.Proxy = string(p)
}

// Usage
c := client.New("AUTH_XYZ", client.Proxy(someProxy))
  

It works exactly the same as the previous one. It required less lines of code. However, it comes at a huge cost - it impaired the documentation.

    type Client struct{ ... }
    func New(authToken string, options ...Option) *Client
type MaxRetries int
type Option interface{ ... }
type Proxy string
type Timeout time.Duration
  

This a good example of how some code duplication can be more valuable than simplification when working on API development. The latter usually comes with some tradeoffs that you need to be mindful about.

Options discoverability

Speaking about documentation, let’s take a look how it changed after switching from function type to an interface.

    type Client struct{ ... }
     func New(authToken string, options ...Option) *Client
-type Option func(*Client)
+type Option interface{ ... }
     func MaxRetries(maxRetries int) Option
     func TimeoutAfter(timeout time.Duration) Option
     func WithProxy(proxy string) Option
  

The change is minimal. The only difference is in the type of our option, everything else remains exactly the same.

As apply function is not exported, we are not exposing the type which option operates on. It removes the temptation to try implementing the options by users. On top of that, it makes any future changes to our code easier. For example, if the Client starts to have a big number of configurable arguments you might want to encapsulate them in a separate structure. Such change would not affect the users in any way. The API and docs would stay exactly the same.

Validating provided options

Many times, provided options must follow a set of rules. In such cases your best bet is adding validation. It ensures correctness of the created structure and informs user about what specifically resulted in a failure.

    type Option interface {
	apply(*Client) error
}

type optionFn func(*Client) error

func (fn optionFn) apply(c *Client) error {
	return fn(c)
}

func New(authToken string, options ...Option) (*Client, error) {
	if authToken == "" {
		return nil, fmt.Errorf("authToken cannot be empty")
	}

	c := &Client{
    authToken: authToken,
  }

	for _, opt := range options {
		if err := opt.apply(c); err != nil {
			return nil, err
		}
	}

	return c, nil
}

func MaxRetries(maxRetries int) Option {
	return optionFn(func(c *Client) error{
		if maxRetries < 0 {
			return fmt.Errorf("maxRetries must be a greater or equal to 0")
		}
		c.maxRetries = maxRetries
		return nil
	})
}
  

Such implementation will return the very first encountered error. If there are more options with invalid values, the user will need to fix them one by one.

As an alternative, we can gather all errors and return them as one message. You can either write the whole logic for that yourself or use an existing library, like go-multierror .

    import "github.com/hashicorp/go-multierror"

func New(authToken string, options ...Option) (*Client, error) {
	cfg := defaultConfig

	merr := &multierror.Error{}
	if authToken == "" {
		multierror.Append(merr, fmt.Errorf("authToken cannot be empty"))
	} else {
		cfg.authToken = authToken
	}

	for _, opt := range options {
		multierror.Append(merr, opt.apply(&cfg))
	}

	if merr.Len() > 0 {
		return nil, merr
	}
	return &Client{cfg: cfg}, nil
}
  

If user provides more than one option for which the validation fails, the output will look similarly to this:

    $ go run main.go
2 errors occurred:
        * authToken cannot be empty
        * maxRetries must be a greater or equal to 0
  

ℹ️  Formatting the output If you don’t like how the error looks, don’t worry! You can easily customise the way error is stringified. This is just the default one.

Options list and presets

Sometimes, users might want to create a list of options. The popular use cases are:

  • Predefining a common options for a repeated task
  • Building options based on specific condition

Those tasks seem trivial. After all, all of them can be solved using only a slice.

    opts := []client.Option{client.MaxRetries(3), client.TimeoutAfter(time.Second*30)}
if someCondition {
	opts = append(opts, client.WithProxy("http://proxy.com:8080"))
}
  

But, what if you want to use those options together with other ones in the constructor? The following code will not even compile.

    c, err := client.New("", client.WithProxy(""), opts) // won't work
if err != nil {
  fmt.Println(err.Error())
}
  

While this use case might seem a little stretched out, I would not say it’s impossible.

A slice is quite limited in its functionality. This is why you might want to consider adding a new option which would allow representing multiple options as one.

    func Options(opts ...Option) Option {
	return optionFn(func(c *Client) error {
		merr := &multierror.Error{}
		for _, opt := range opts {
			multierror.Append(merr, opt.apply(c))
		}
		return merr
	})
}
  

This solves the mentioned issue. Arguably, it also makes building options more friendly and readable.

Predefining common options, or so called presets, isn’t limited to the users of our code. While this applies mostly to internal libraries, you might want to export some presets if there are common combinations of options for your structure.

    func Retrying() Option {
 return Options(MaxRetries(3), client.TimeoutAfter(time.Second*30)
}
  

Final words

Hopefully, I have successfully showcased the flexibility and usefulness of the options pattern. Its adaptability allows it to be applied to various use cases and customised to fit your specific needs.

I encourage you to experiment, modify and extend the ideas presented here. Dive deeper into the world of options pattern and be creative. Happy coding! 💻

GoDeveloper Experience