GO-TIP-001 Variadic Options
You want to support a number of optional custom fields for a function or type constructor, but you need something more sophisticated than the zero value. Perhaps you want to deliver an option that performs some logic first.
Recommendation
Follow the variadic Option pattern!
Define an
Optioninterface like:type config struct { /* your config */ } type Option interface { apply(*config) }Define functions that return
Optionobjects:func WithFoo(...) Option { ... } func WithBar(...) Option { ... }Collect
Options variadically, and use the build configuration:cfg := config{ /* defaults */ } for _, opt := range opts { opt.apply(&cfg) }
Why
Using variadic options with a sealed interface has a huge number of
benefits, especially when compared to an type Options struct-pattern:
You don’t have to rely on the zero value for defaults. You can use semantic and reasonable defaults easily.
You can provide more than one way to assign the same value, which enables offering either static or dynamically-computed values
Since
Optionis an interface, you can make it as complex as you need – even returning anerrorif you want options to be capable of erroring.When cleverly combined with Go’s
internalpackages, you can expose differentOptions in internal-facing pacakges, external packages, and even test-specific options for test-helper packages.Options can compose on top of otherOptions. This enables powerful compositions, where you can add more logic to existing functions – and allow clients of your library to add their own customOptions.
Example
Without this pattern
Without this pattern, you might be tempted to write something like:
type GitHubOptions struct {
// Only one of 'Token' or 'CLientID'/'PEM' can be used to generate a client
// token.
// Token is a GitHub token
Token string
// ClientID is the client installation ID for a GitHub application.
ClientID string
// PEM is the private key for a GitHub application.
PEM []byte
}
func NewClient(opts *GitHubOptions) (*Client, error) {
if opts.Token != "" {
// handle static
} else if opts.ClientID != "" && opts.PEM != nil {
// handle github application token
}
// ...
}
This requires branching behavior in one larger, monolithic construction function – all to support assigning the same underlying value: A token.
With this pattern
Using this pattern, you can create an Option hierarchy that define different
functions that compute or determine the value statically:
type config struct {
token string
// ...
}
type Option interface {
apply(*config) error
}
type option func(*config) error
func (o option) apply(cfg *config) error {
return o(cfg)
}
func StaticToken(token string) Option {
return option(func(cfg *config) error {
cfg.token = token;
return nil
})
}
func AppToken(clientID string, PEM []byte) Option {
return option(func(cfg *config) error {
// compute the client and PEM, return any errors
})
}
func NewClient(opts...Option) (*Client, error) {
cfg := &config{
// set any defaults
}
// apply all options, return any errors
for _, opt := range opts {
if err := cfg.apply(cfg); err != nil {
return nil, err
}
}
// use the config, and return the client
}
Note
With this pattern, you can now have options that perform work. This is a powerful improvement over atype Options struct approach.Additional Notes
The approach using a sealed
interface is preferred over a func-based definition like:
type Option func(*config)
A func-based definition exposes an unnamable/unexported type, and leaks
implementation details that they don’t need to know to the caller. It also
exposes the ability to reflect over the signature to get this type name for
the caller, which means the caller can construct your unexported type. This
leads to a leaky abstraction.