Reusable and type-safe options for Go APIs

October 21, 2017

Japanese translation by frasco


Background

In this blog post, I would like to describe an extension to the popular “functional options” pattern that has been described by people like Rob Pike and Dave Cheney. I recommend reading one of these articles if you are unfamiliar with this pattern, as it’s very useful in practice.

The problem

To see the limitations of the pattern, consider the etcd v3 client. Specifically, let’s look at the KV interface which exposes APIs for putting and getting key-value pairs. Here’s the Get API for instance:

Get(ctx context.Context, key string, opts ...OpOption) (*GetResponse, error)

Here, opts is a list of functional options. To use this API, you write code like this:

resp, err := kvc.Get(ctx, "sample_key", WithPrefix(), WithRev(sample_rev))

Here we are passing two options WithPrefix and WithRev to the API. Note how both options are functions and therefore can take arbitrary arguments themselves.

The problem with this approach though is that you can pass any option with the type OpOption to the API, whether the option actually makes sense or not. For instance, we could pass WithLease to Get, even though the option only applies to Put, as described in the documentation.

Therefore, we say that the options are not “type safe”, in that passing the wrong options is an error detectable only at run time, not compile time.

The wrong solution

It’s tempting to solve this problem by defining separate option types for different APIs. For instance, we could have a GetOption type which only Get accepts, and a PutOption type which only Put accepts, so on and so forth.

The problem with this approach is that different APIs might take the same options. Since Golang does not support function overloading, you’d have to define separate instances of the same option, one for each API, like this:

func WithPrefixForGet() GetOption { ... }
func WithPrefixForDelete() DeleteOption { ... }
func WithPrefixForWatch() WatchOption { ... }

Which is clearly less than ideal for both the developer and the user. You could also define the options in different packages, one for each API, but that’s even more cumbersome.

The solution

I’ve put up an example here. For simplicity we only support two APIs (Get and Delete) and two options (WithPrefix and WithRev).

We start by defining the APIs:

func Get(key string, ops ...GetOption)
func Delete(key string, ops ...DeleteOption)

Then, we define one interface per API:

type GetOption interface {
	SetGetOption(*getOptions)
}

type DeleteOption interface {
	SetDeleteOption(*deleteOptions)
}

Finally, we define one function per option.

// WithPrefix can be used with Get and Delete
func WithPrefix() interface {
    GetOption
    DeleteOption
} {
    // See the link above for the implementation
}

// WithRev can be used only with Get
func WithRev(rev int64) interface {
    GetOption
} {
    // See the link above for the implementation
}

One interesting thing to note here is that the functions return anonymous interfaces that embed the *Option interfaces. This has the benefit that the code becomes self-documenting (and thus godoc-friendly), in that you can look at the type signature of an option and instantly know which APIs it can be used with.

Now let’s see if the options are in fact reusable and type-safe. Since WithPrefix() implements both GetOption and DeleteOption, the following code works with no issues:

Get("sample_key", WithPrefix())
Delete("sample_key", WithPrefix())

In contrast, if we use WithRev with Delete, we get a compile error:

Delete("sample_key", WithRev(1))
./main.go:88:30: cannot use WithRev(1) (type interface { SetGetOption(*getOptions) }) as type DeleteOption in argument to Delete:
        interface { SetGetOption(*getOptions) } does not implement DeleteOption (missing SetDeleteOption method)

Which tells us that WithRev is not a DeleteOption!

Summary

To summarize, I have described a pattern for defining options that are:

  • Reusable, in that the same option can be used in multiple APIs.
  • Type-safe, in that we get a compile-time error if we pass the wrong option.

I would like to thank JD for discussing this pattern with me.

Thanks to @ar1819 and @natefinch from r/golang for suggesting the use of anonymous interfaces.

Thanks to Ren Sakamoto for translating the article into Japanese.

Discuss on Hacker News.

comments powered by Disqus