Generic Functional Options for Friendly API Creation
Introduction
Functional options were introduced by Dave Cheney in a blog post late 2014 https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis. They are a user-friendly mechanism in Go to configure the behavior of functions or methods. This approach is particularly useful when dealing with functions that have a large number of optional parameters or when introducing new optional features without breaking backward compatibility. In this post, we’ll take them one step further.
Genericizing
We can leverage generics to create a reusable set of helpers for working with functional options.
package fnopt
// OptFn is a function type used to define functional options for mutating
// the generic type `T`
type OptFn[T any] func(cfg *T)
// New creates a new struct pointer of type `T` and modifies it by applying
// the provided option functions to it.
func New[T any](optFns ...OptFn[T]) *T {
t := new(T)
From(t, optFns...)
return t
}
// From modifies an existing struct pointer of type `T` by applying the
// provided option functions to it.
func From[T any](t *T, optFns ...OptFn[T]) {
for _, optFn := range optFns {
optFn(t)
}
}
Between these 3 definitions, we can remove some of the repetitiveness experienced during the setup of functional options.
Application
Let’s see how we can apply these generic functional option helpers in a practical example.
Type and constructor definitions
// server.go
package api
import (
"crypto/tls"
"net"
"time"
"path/to/fnopt"
)
type Server struct {
listener net.Listener
timeout time.Duration
maxConns int
cert *tls.Certificate
}
func NewServer(addr string, optFns ...serverOptFn) (*Server, error) {
l, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
srv := &Server{
listener: l,
maxConns: 50,
timeout: time.Minute,
}
fnopt.From(srv, optFns...)
return srv, nil
}
Applying the functional options for Server
is offloaded to fnopt
with the From
helper. serverOptFn
, as we’ll see next, is a type that interops with the fnopt
helper functions.
Options definitions
Options are defined in an options.go
file to enable quick and idiomatic discovery of your packages options.
// options.go
package api
import (
"crypto/tls"
"time"
"path/to/fnopt"
)
type serverOptFn = fnopt.OptFn[Server]
func ServerWithTimeout(timeout time.Duration) serverOptFn {
return func(cfg *Server) {
cfg.timeout = timeout
}
}
func ServerWithMaxConns(maxConns int) serverOptFn {
return func(cfg *Server) {
cfg.maxConns = maxConns
}
}
func ServerWithCert(cert *tls.Certificate) serverOptFn {
return func(cfg *Server) {
cfg.cert = cert
}
}
Traditionally, functional options are prefixed using
With*
. Depending on the surface area of the package, I will prefix options using{struct/method-name}With*
.
Here we leverage fnopt.OptFn
to define serverOptFn
which allows interoperability with fnopt
helpers. Co-locating serverOptFn
with your options enables convenient discovery through jump to definition/usage. Alternatively, if serverOptFn
is not explicitly defined and you instead inline fnopt.OptFn[Server]
in NewServer
and each option, jump to definition/usage will not be helpful.
Caller
None of what we’ve done will have any impact on the packages interface, ensuring that callers can continue to enjoy the functional options it provides.
package main
import (
"log"
"time"
"path/to/api"
)
func main() {
server, err := api.NewServer(
":3000",
api.ServerWithMaxConns(25),
api.ServerWithTimeout(time.Second*30),
)
if err != nil {
log.Fatal(err)
}
}
While the callers interface is lexically/syntactically the same, it’s important to note that the underlying reflected types would be different. As such, poorly written tests in calling code could break by migrating an existing functional options interface to use this pattern of generic functional options.
Conclusion
In this blog post, we explored the concept of functional options in Go, which allows us to configure constructor functions or methods in a user-friendly manner. We then took it a step further and applied generics, resulting in a more flexible, reusable, and developer-friendly approach. By embracing generic functional options, we can improve the experience of creating friendlier APIs for our users.
Thanks for reading, share your thoughts below!
I’ve packaged the helpers seen here as well some error-enabled versions at
github.com/dillonstreator/fnopt
. Give it a look and star, if you’ve gotten any value from this.