The functional options pattern lets you configure Go structs with a variable number of optional parameters, bypassing the common pitfalls of constructor overloading or mandatory fields.
Let’s see it in action. Imagine you’re building a Server struct that needs to listen on a specific host and port, and might optionally have a timeout and a tlsConfig.
package main
import (
"crypto/tls"
"fmt"
"net"
"time"
)
type Server struct {
host string
port int
timeout time.Duration
tlsConfig *tls.Config
listener net.Listener
}
// Option is a function that configures a Server.
type Option func(*Server)
// WithTimeout sets the timeout for the server.
func WithTimeout(t time.Duration) Option {
return func(s *Server) {
s.timeout = t
}
}
// WithTLSConfig sets the TLS configuration for the server.
func WithTLSConfig(cfg *tls.Config) Option {
return func(s *Server) {
s.tlsConfig = cfg
}
}
// NewServer creates a new Server with the given host and port,
// and applies any provided functional options.
func NewServer(host string, port int, opts ...Option) (*Server, error) {
srv := &Server{
host: host,
port: port,
timeout: 30 * time.Second, // Default timeout
}
// Apply all the options
for _, opt := range opts {
opt(srv)
}
// Create the listener
addr := fmt.Sprintf("%s:%d", srv.host, srv.port)
if srv.tlsConfig != nil {
srv.listener = tls.NewListener(newDirectListener(addr), srv.tlsConfig)
} else {
srv.listener = newDirectListener(addr)
}
return srv, nil
}
// Helper to create a net.Listener directly, bypassing some complexities for this example
type directListener struct {
addr net.Addr
ch chan net.Conn
}
func newDirectListener(addr string) *directListener {
return &directListener{
addr: &tcpAddr{name: addr},
ch: make(chan net.Conn),
}
}
func (l *directListener) Accept() (net.Conn, error) {
conn, ok := <-l.ch
if !ok {
return nil, fmt.Errorf("listener closed")
}
return conn, nil
}
func (l *directListener) Addr() net.Addr { return l.addr }
func (l *directListener) Close() error { close(l.ch); return nil }
type tcpAddr struct{ name string }
func (a *tcpAddr) Network() string { return "tcp" }
func (a *tcpAddr) String() string { return a.name }
func main() {
// Server with default timeout
server1, err := NewServer("localhost", 8080)
if err != nil {
panic(err)
}
fmt.Printf("Server 1: Host=%s, Port=%d, Timeout=%s, TLS=%v\n",
server1.host, server1.port, server1.timeout, server1.tlsConfig != nil)
// Server with custom timeout and TLS config
tlsConfig := &tls.Config{} // In a real app, this would be properly configured
server2, err := NewServer("localhost", 8443,
WithTimeout(5*time.Minute),
WithTLSConfig(tlsConfig),
)
if err != nil {
panic(err)
}
fmt.Printf("Server 2: Host=%s, Port=%d, Timeout=%s, TLS=%v\n",
server2.host, server2.port, server2.timeout, server2.tlsConfig != nil)
// Server with only TLS config
server3, err := NewServer("localhost", 9000, WithTLSConfig(nil)) // Explicitly nil TLS
if err != nil {
panic(err)
}
fmt.Printf("Server 3: Host=%s, Port=%d, Timeout=%s, TLS=%v\n",
server3.host, server3.port, server3.timeout, server3.tlsConfig != nil)
}
The core idea is that Option is a type alias for a function that takes a pointer to the struct being configured (*Server) and modifies it. NewServer accepts a variadic slice of these Option functions (opts ...Option). Inside NewServer, after initializing the essential fields, it iterates through the opts slice and calls each function, passing the partially initialized srv object. This allows each option function to selectively override default values or add new configurations.
This pattern elegantly solves several problems. First, it avoids the "telescoping constructor" issue where you’d need multiple NewServer functions with different parameter lists for various combinations of optional settings. Second, it prevents the need for mutable setters on the Server struct itself, which can lead to race conditions and make the object’s state harder to reason about. Each Option function is a pure function in the sense that it only modifies the struct it’s given, and the NewServer function ensures all modifications happen atomically before the Server object is returned.
When defining options, it’s idiomatic to use a WithXxx function that returns an Option function. This way, you can encapsulate any logic needed to prepare the value being set (like creating a tls.Config or parsing a duration string) within the option factory function itself. The returned function then simply applies that pre-processed value. This keeps the NewServer function clean and focused on orchestration, delegating the specific configuration details to the option functions.
You might have noticed that NewServer accepts host and port as mandatory positional arguments. This is a common and good practice. You only use functional options for parameters that are truly optional or have sensible defaults. If a parameter is essential for the object’s creation, it should be a direct argument to the constructor. The Server struct also has a default timeout of 30 * time.Second. If an option isn’t provided, the default remains. This is crucial for making the pattern usable; not every field needs an option.
The most surprising thing about this pattern is how it handles internal state or dependencies that can’t be directly set. For example, if Server had a complex http.Server field that needed to be configured, the Option function could also be responsible for creating and configuring that nested struct. It’s not just about setting primitive fields; it’s about orchestrating the entire setup process of the struct through these discrete, composable functions.
The next step is often to integrate this pattern with dependency injection frameworks or to use it for configuring more complex, nested structures within your application.