Level-Up With Functional Options in Go

Irsyad Nabil
Gravel Product & Tech
4 min readAug 14, 2023

--

Hey everyone!

I’d like to share about a design pattern in Go called Functional Options, and it’s really handy whenever you need to build complex function parameters and need a way to cater for default values and have certain common configuration options where an abstraction would be beneficial.

Humble beginnings

Let’s say you’re writing a function that takes in a lot of parameters, for example building a request with a number of optional parameters:

// Very long function signature, the params go on forever
// The only required param here is ctx
func (*orderRepository) FetchAll(ctx context.Context, keyword, field *string, filterByStatus []string, sortField, sortOrder *string, ...) ([]*Orders, error)

Initially, this could get really ugly.

It has a very long function signature, therefore difficult to call especially when you have some params you want to set as nil or default value, and incremental changes to the code requires changing the entire function signature which could break other code.

In addition, this can make our code harder to read since we’re only passing values to the function calls without explicitly stating what the values are for.

// Not fun to read at all!
res, err := FetchAll(ctx, "Royal Residence", "address", []{"new"}, "created_at", "desc")

Upgrade with options struct

We then resolve to using structs to contain our parameters. This is usually referred to as options struct.

type FetchAllOpts struct {
Keyword *string
Field *string
FilterByStatus []string
SortField *string
SortOrder *string
Page int
PerPage int
}

Here we have defined all of our parameters in a single option struct, called FetchAllOpts. That way, we could just have FetchAllOpts as a parameter to our function (*orderRepository).FetchAll like so:

// Definition
func (*orderRepository) FetchAll(ctx context.Context, opts *FetchAllOpts) ([]*Orders, error) {
...
}

// Usage
res, err := orderRepo.FetchAll(ctx, &FetchAllOpts{
Keyword: "Royal Residence",
Field: "address",
FilterByStatus: []string{"new"},
SortField: "name",
SortOrder: "asc",
Page: 2,
PerPage: 15,
})

Now this certainly has its merits, and for functions that do not require a lot of param fields, this is the way to go. This pattern is also commonplace in the usage of various libraries.

Default values

At first, it might seem that adding option struct did not change our code much in terms of complexity. But when we set defaults inside the function implementation, it becomes apparent that we can skip setting a lot of the keys in the struct if we want the default value.

For example, the initial steps in the function could look like this:

func (*orderRepository) FetchAll(ctx context.Context, opts *FetchAllOpts) ([]*Orders, error) {
// Set defaults in the beginning of the function

if len(opts.FilterByStatus) == 0 {
opts.FilterByStatus = []string{"new", "ongoing", "done"}
}

if opts.SortField == nil {
opts.SortField = "created_at"
}

if opts.SortOrder == nil {
opts.SortOrder = "desc"
}

if opts.Page < 1 {
opts.Page = 1
}

if opts.PerPage < 1 {
opts.PerPage = 10
}

...
}

This will save us a lot of work and our function becomes easier to call or customize:

// Simpler call, other params are default.
// The default for FilterByStatus is overridden
res, err := orderRepo.FetchAll(ctx, &FetchAllOpts{
Keyword: "Royal Residence",
Field: "address",
FilterByStatus: []string{"done"},
})

But what if our options struct becomes more complex? What if the number of keys increase or some of the keys become nested? Is there a way to introduce abstraction to make the function easier for callers to use?

Functional options to the rescue

In functional options, instead of providing an options struct containing our arguments we provide function closures instead. Closures are functions that have variables that are bound to certain values set when the closure itself is defined.

Take this for an example, utilizing our previous code: here we define a few functional options that return closures that take in the optional struct and modifies its attributes.

type FetchAllOption func(*FetchAllOpts)

func WithSort(field, order string) FetchAllOption {
return func(opts *FetchAllOpts) {
opts.SortField = field
if order == "desc" || order == "asc" {
opts.SortOrder = order
} else {
opts.SortOrder = "asc"
}
}
}

func WithStatusesFilter(statuses... []string) FetchAllOption {
return func(opts *FetchAllOpts) {
opts.FilterByStatus = append(opts.FilterByStatus, statuses...)
}
}

In addition, our original FetchAll function will take in a variadic parameter to receive any number of our functional options. The function may also apply its default options before applying our functional options, as illustrated below:

// Turn this into a variadic function!
func (*orderRepository) FetchAll(ctx context.Context, opts ...FetchAllOption) ([]*Orders, error) {
defaultOpts := []FetchAllOption{
WithSort("created_at", "desc"),
}

options := &FetchAllOpts{}

// Apply default options
for _, opt := range defaultOpts {
opt(options)
}

// Apply overrides from variadic params
for _, opt := range opts {
opt(options)
}
...
}

And now, we can call the function like so:

res, err := orderRepo.FetchAll(ctx, []FetchAllOption{
WithStatusesFilter("new", "ongoing"),
WithSort("address", "asc"),
})

Furthermore, this enables cases when we need to gather our options into an array and build them as we go:

// Initialize default options for this usecase
fetchAllOptions := []FetchAllOption{
WithStatusesFilter("new", "ongoing", "done"),
WithPagination(pagination.Page, pagination.Size),
...
}

// Some logic here
if currentUser.Type == entities.FieldOpsUserType {
fetchAllOptions = append(
fetchAllOptions,
WithScoping(entities.FieldOpsUserType),
)
}

// Call with our functional options array
res, err := orderRepo.FetchAll(ctx, fetchAllOptions)

Remarks

Functional options enable us to write code with a common use case with default arguments, then override these arguments according in a way that better reflects our business use case.

In addition, our functional options are composable, flexible, able to be stored beforehand and can be manipulated as values since the options are values themselves.

However, functional options does require a large amount of coding upfront. I recommend using this pattern for cases when interacting with interfaces that are mostly static and well-defined, such as when building a client for an API such as ElasticSearch, where you need to build your query object based on different use cases such as filter, sorting, queries, etc.

That’s all for now, I hope you enjoyed my post and applied your knowledge of functional options in Go in your day-to-day coding!

--

--