Have you ever used a library where the config has like 10+ options?
And here’s the fun part, you really only care about 4 of them. The rest? Optional.
But you still have to configure them anyway. Painful, especially in typed languages where every param has to be accounted for.
Enter functional options.
The idea is simple: any configurable object should be initialized with all its defaults baked in, so the api user only "edits" the parts they care about.
In JavaScript (especially Node.js), you’ve probably seen this kind of pattern with Object.assign
:
class Lib {
config_
constructor(config = {}) {
config_ = Object.assign(config_, config) // override only what you care about
}
}
This works because JS is dynamic. But in Go, we need a pattern to get similar flexibility, and that’s where functional options come in.
Full Code(including prev article):
git clone https://github.com/sklyt/goway.git
Functional Options in Go
Let’s say our Online Store needs an HTTP client.
First, create a new folder in your storage
project:
internal/
client/
http.go
Now in http.go
:
package gohttplib
type Client struct {
httpClient *http.Client
Baseurl string
BearerToken string
Headers map[string]string
}
// All defaults are initialized here
func NewClient(base string, opts ...Option) *Client {
c := &Client{
httpClient: &http.Client{Timeout: time.Second * 5},
Baseurl: base,
BearerToken: "",
Headers: map[string]string{
"X-Custom-Header": "CustomValue",
"X-Client-Version": "1.0.0",
"Cache-Control": "no-cache",
},
}
for _, o := range opts {
o(c) // Apply each option
}
return c
}
That second parameter here?
opts ...Option
It’s a slice of functions, each one editing the config. This is the magic behind functional options.
Let’s define those functions now.
opts.go
In the same client
folder, create opts.go
:
package gohttplib
import "time"
type Option func(*Client)
func WithTimeOut(d time.Duration) Option {
return func(c *Client) {
c.httpClient.Timeout = d
}
}
func WithBearerToken(token string) Option {
return func(c *Client) {
c.BearerToken = token
}
}
func WithHeaders(headers map[string]string) Option {
return func(c *Client) {
c.Headers = headers
}
}
Let’s zoom in on WithTimeOut
:
func WithTimeOut(d time.Duration) Option {
return func(c *Client) { c.httpClient.Timeout = d }
}
It returns a function that takes in the client and updates the Timeout
.
So when you use it:
gohttplib.NewClient(url, WithTimeOut(100*time.Second))
You’re passing in a function that modifies just the timeout field. Then inside NewClient
, we run it:
for _, o := range opts {
o(c)
}
That’s it!
Because opts
is a variadic slice, you can pass in as many With
functions as you want.
Putting It Into Online Store
Let’s update the OnlineStore
constructor:
func NewOnlineStore(url string, httpOpts ...gohttplib.Option) *OnlineStore {
return &OnlineStore{
Logger: logger.NewLogger("ONLINE"),
Client: gohttplib.NewClient(url, httpOpts...),
}
}
Now in main.go
, we can customize the client like this:
func main() {
online := storage.NewOnlineStore(
"https://jsonplaceholder.typicode.com",
gohttplib.WithTimeOut(2*time.Second),
gohttplib.WithBearerToken("secret-token-123"),
)
// Use online store...
}
Easy. No more bloated config objects or unused boilerplate params.
Top comments (2)
Love this, but now every time I see more than two arguments in a Go constructor, I get PTSD flashbacks to config hell. Thanks for making my future code reviews just a tiny bit more tolerable!
😭😭🤣 Understandable