Dlog

Generic Functional Options for Friendly API Creation

📖 4 min read

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.