Idiomatic Go: Mastering the Functional Options Pattern
"Master the Functional Options pattern in Go to build flexible, backward-compatible, and idiomatic APIs without constructor explosion."
Idiomatic Go: Mastering the Functional Options Pattern
Go lacks method overloading, default parameter values, and traditional constructors. Developers often resort to anti-patterns when initializing complex structs: constructor functions with dozens of arguments, or unwieldy Config structs full of nil checks.
The Functional Options pattern solves this elegantly. It provides clean APIs, sensible defaults, and backward compatibility.
The Problem: Constructor Parameter Explosion
Consider a network client struct:
type Client struct {
timeout time.Duration
retries int
logger *log.Logger
tls bool
}
Anti-Pattern 1: Telescoping Constructors
func NewClient(timeout time.Duration) *Client { ... }
func NewClientWithRetries(timeout time.Duration, retries int) *Client { ... }
func NewClientWithLogger(timeout time.Duration, retries int, logger *log.Logger) *Client { ... }
Breaks as options grow. Pollutes package namespace.
Anti-Pattern 2: The Plain Config Struct
type Config struct {
Timeout time.Duration
Retries int
Logger *log.Logger
TLS bool
}
func NewClient(cfg Config) *Client { ... }
Problem: Cannot distinguish zero values (0, false, nil) from unset fields without pointers everywhere.
The Solution: Functional Options
Define an option type as a function signature that mutates the struct:
package client
import (
"log"
"time"
)
type Client struct {
timeout time.Duration
retries int
logger *log.Logger
tls bool
}
// Option defines a functional configuration signature.
type Option func(*Client)
// WithTimeout sets request timeout.
func WithTimeout(d time.Duration) Option {
return func(c *Client) {
c.timeout = d
}
}
// WithRetries configures retry count.
func WithRetries(n int) Option {
return func(c *Client) {
c.retries = n
}
}
// WithLogger injects custom logger.
func WithLogger(l *log.Logger) Option {
return func(c *Client) {
c.logger = l
}
}
// WithTLS enables secure connection.
func WithTLS(enabled bool) Option {
return func(c *Client) {
c.tls = enabled
}
}
Constructor Implementation
Set defaults first. Apply variadic options after:
const (
defaultTimeout = 30 * time.Second
defaultRetries = 3
)
func New(opts ...Option) *Client {
// 1. Set sane production defaults
c := &Client{
timeout: defaultTimeout,
retries: defaultRetries,
logger: log.Default(),
tls: true,
}
// 2. Apply custom overrides
for _, opt := range opts {
opt(c)
}
return c
}
Usage at Call Sites
Clean and readable:
// Default client
c1 := client.New()
// Custom timeout and retries only
c2 := client.New(
client.WithTimeout(5*time.Second),
client.WithRetries(5),
)
Handling Validation & Errors
If option validation can fail, return an error from the closure:
type Option func(*Client) error
func WithTimeout(d time.Duration) Option {
return func(c *Client) error {
if d <= 0 {
return errors.New("timeout must be positive")
}
c.timeout = d
return nil
}
}
func New(opts ...Option) (*Client, error) {
c := &Client{timeout: defaultTimeout}
for _, opt := range opts {
if err := opt(c); err != nil {
return nil, fmt.Errorf("client configuration failed: %w", err)
}
}
return c, nil
}
Summary & Trade-offs
| Factor | Functional Options | Config Struct |
|---|---|---|
| API Cleanliness | Excellent | Average |
| Default Handling | Automatic | Requires manual merge |
| API Evolution | Non-breaking | May require struct changes |
| Overhead | Heap allocation per option closure | Stack allocation |
Rule of thumb: Use Plain Structs for internal/unexported code. Use Functional Options for exported public libraries and core infrastructure clients.
About the Author
huud
@huud
Systems architect and software engineer building high-performance distributed platforms.