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.