Functional_options_golang
Lets say…⌗
You are the maintainer of a widely used function in a fairly large service. This function abstracts away all of the boilerplate necessary to log an error. The wider service has several different debugging tools. For example, it could:
- Log errors to Rollbar
- Instrument OpenTelemetry
- Support logging to stdout, to disk, or an async log aggregator.
the list can theoretically go on and on.
LogErrors’s Humble Beginnings⌗
To start, you write your function to support your own use case, and make the api easy to consume for others.
func LogError(e error, msg string) {
// assume a logger is configured
logger.Error(e.Error + ": " + msg)
}
This is a simple enough function that formats an error and invokes the configured logger at the Error level.
LogError’s Organic Growth⌗
As this service matures, things can get out of hand. Let’s introduce some other, optional dependencies in a naive way.
func LogError(e error, msg string, shouldRollbar bool, spanCtx context.Context) {
fmtErr := e.Error + ": " + msg
if shouldRollbar {
rollbar.Error(fmtErr)
}
if spanCtx != nil {
span := otel.NewSpan("LogError", spanCtx)
defer span.End()
span.RecordError(fmtErr)
}
logger.Error(fmtErr)
}
This is fine and functional, but you can see how adding additional, optional features requires a full refactor of every function call. Furthermore, the caller has to populate these arguments even if they are falsy or nil
.
A Better Way⌗
We can enforce some required arguments by defining them as we would before. Let’s say that err
and msg
are required params. However, logging to a span or triggering a Rollbar are completely optional. We can save the caller a hastle by not requiring them in the function call at all by using the functional options pattern.
Functional Options⌗
We need to create an option
type that we use internally to drive the logic for behavior. Leave this unexported so the caller doesn’t try and use it directly.
type option struct {
withRollbar bool
withContext context.Context
}
The real magic happens when we create a type that returns a function that accepts the option as an argument.
type Options func(*option)
We now create functions that return the Options
type that the caller can use.
func WithRollbar() Options {
return func(o *option) {
o.withRollbar = true
}
}
func WithContext(ctx context.Context) Option {
return func(o *option) {
o.withContext = ctx
}
}
Leveraging Functional Options⌗
All of the pieces are coming together, but as LogError
stands, it does not use options. Let’s fix that by first changing the function signature.
func LogError(err error, msg string, opts ...Options) {
// function body
}
Now the err
and message
arguments are required by the compiler (even if nil or empty). The ...Options
syntax indicates that LogError
is now a variadic function, and that the trailing arguments is a slice of length >= 0. The compiler will not enforce that any argument is present for the variadic argument. Functionally this means that opts
is entirely optional! However, we still need to leverage these options within the function.
func LogError(err error, msg string, opts ...Options) {
fmtErr := err.Error() + ": " + msg
// instantiate an option struct to populate with
// provided options
o := options{}
// iterate through the provided options to populate
// data on the struct
for _, opt := opts {
opt(o)
}
// start performing logic based on provided options
if o.withRollbar {
rollbar.Error(fmtErr)
}
if o.withContext != nil {
span := otel.NewSpan("LogError", spanCtx)
defer span.End()
span.RecordError(fmtErr)
}
logger.Error(fmtErr)
}
You can see the function body doesn’t change all that much. But we have significatly reduced the signature and uncomplicated the things the caller needs to worry about.
Using LogError⌗
With functional arguments, all of these are valid invocations of LogError
.
err := errors.New("there was an error!")
msg := "could not perform operation"
ctx := context.Background()
LogError(err, msg)
LogError(err, msg, WithContext(ctx))
LogError(err, msg, WithRollbar())
LogError(err, msg, WithContext(ctx), WithRollbar())
LogError(err, msg, WithRollbar(), WithContext(ctx))
Consumers of LogError
now no longer need to worry about providing a nil context or falsy shouldRollbar
value if they are unconcerned about those features.
Extending LogError⌗
Now that LogError
is a variadic function, we can add more options without needing to refactor any of its consumers. Let’s implement optionally logging to disk instead of standard out.
type option struct {
withRollbar bool
withContext context.Context
withLogToDisk bool // new field
}
func WithLogToDisk() Option {
return func(o *option) {
o.withLogToDisk = true
}
}
func LogError(err error, msg string, opts ...Options) {
fmtErr := err.Error() + ": " + msg
// instantiate an option struct to populate with
// provided options
o := options{}
// iterate through the provided options to populate
// data on the struct
for _, opt := opts {
opt(o)
}
// start performing logic based on provided options
if o.withRollbar {
rollbar.Error(fmtErr)
}
if o.withContext != nil {
span := otel.NewSpan("LogError", spanCtx)
defer span.End()
span.RecordError(fmtErr)
}
if o.withLogToDisk {
// helper function assumed to exist
logErrorToDisk(fmtErr)
return
}
logger.Error(fmtErr)
}
LogError
is now more functional for consumers who wish to leverage this option. However, because of the variadic nature of LogError
, we don’t need to do a single bit of refactoring the consumers and the api is unchanged. Anyone who wishes to log to disk instead of to standard out can do so like this:
err := errors.New("there was an error!")
msg := "could not perform operation"
LogError(err, msg, WithLogToDisk())
That’s Pretty Neat⌗
There is the overhead of planning for which arguments are required and which are options when maintaining a function such as LogError
. However, if you know that the function will be broadly consumed from users with differing needs then utilizing functional options is a great way to implement those dependencies without forcing them on every consumer. Functional options allow for easily extending functionality without requiring large refactors for the consumers of its api.