Functional Options in Go: With Generic

In this story, we will know the Functional Options pattern and implement it simply, and at the end, we will learn how to apply Generic (Go +1.18) to this solution.
Introduction
Functional options are a method of implementing clean APIs in Go. You've probably seen this pattern if you have done some integrations with GCP SDK or gRPC.
As you can see above, we can configure the clients by using optional functions, and we don't have to use a separate function for different purposes. E.g.
Implementation
This pattern allows us to design a flexible set of APIs to help arbitrary configurations and initialization of a struct.
Let's assume we have a client struct and it has two configurable fields:
Typically, we would need to construct the struct first and then modify its values if we wanted a different variant — first of all, we need to make these fields public. But with this pattern, we can reduce the complexity and give a list of options to the constructor itself. Since the user won't modify these fields directly, having a field/option deprecation plan would be much easier.
Let's define a function type that accepts a pointer to this struct.
Why pointer? Because we want to modify the existing struct.
The user sends option(s), and now we need setter functions to apply those inputs to the struct:
It's time to create our constructor function:
Time to test our constructor function with some spices (options):
The output must be the following values:
So far, we've understood how this pattern works. Now let's think about some improvements to this implementation.
🌸 FYI: No sweat! This is easy to deprecate an option because we can simply let an existing option function not effecting anymore or behave differently.
When we talk about an improvement strategy, the first answer usually is It depends!
. What if StorageClient
contains many other internal fields? Do you think putting our option fields between them is a good idea? My preference for improving the StorageClient
structure is to put all options under a dedicated field.
By taking this approach, we have an organized structure. In addition, we can use this as a shared option between other clients (StorageClient
, MailClient
, etc.) as well.
Issue!
This solution is not very good and, like many other things in the engineering world, has some disadvantages (We always deal with trade-offs, Right?). In addition, it scales hideous; at some point, if we need to use these options in two or more different structs, we have to have many options and multiple types in one package. Maybe you think we can use a shared options struct like above, but It won't apply to most scenarios — We can take this approach only if we have a different client which needs the same options — but it does not sound scalable and customizable!
Let me explain this through an example. Let's say MailClient
needs another option which is secret
. If we add this to clientOptions
struct, basically we've added an option to a shared options struct that StorageClient
doesn't need it!
Another Option Set
To solve this problem, we have to create a dedicated option set and another type
which can lead us to something very ugly. 😥
The next problem with this solution is now we have to create all With*
for mailClientOptions
. I think you can imagine the output 😶.
Using `Interface{}`
A quick solution to deal with this problem is to use interface{}
as a function type. E.g.
As you can see, It solves the problem. Since both clients use the same option interface, we can use WithSecret
for NewStorageClient
🙊 . But this behavior is not what we expect because Storage doesn't do anything with secret
.
Generic
From the Go 1.18, we have a nice feature which is Generic. Considering this feature, we can simplify the previously mentioned solution and also make the solution safer and more user-friendly.
In the example below, We created a shared struct SharedClientOptions
and added it to Mail
and Storage
. but Mail
struct has another field for itself secret
.
As you've realized, WithTimeout
and WithUserAgent
accept any type. It makes sense because they are shared options, but WithSecret
only accepts MailClientOptions
type.
👾 Better to specify the types instead of using
any
Let's modify our clients:
Here, we specified the option type, so New*
functions won't accept other option types.
func NewStorageClient(opts ...ClientOption[StorageClientOptions])...
func NewMailClient(opts ...ClientOption[MailClientOptions])...
Line:10 we must receive an error because WithSecret
doesn't accept StorageClientOptions
type.
Conclusion
We've tried different solutions, from basic to generic, which is something new in Golang. But as always, It depends. So it would be best if you considered all aspects beforehand. Anyway, It was one pattern that I explained; there are other ways you can also implement this feature.
💁♂ There’s always room for improvement