Why Every Go Dev Eventually Discovers Functional Options
The functional options pattern elegantly solves Go's lack of optional parameters. Learn why it's everywhere in production Go code.
You're building an HTTP client. Starts simple:
func NewClient(url string) *Client
A week later someone asks for timeouts. Then headers. Then retries. Suddenly you're maintaining this:
func NewClient(url string)
func NewClientWithTimeout(url string, timeout time.Duration)
func NewClientWithHeaders(url string, headers map[string]string)
func NewClientWithTimeoutAndHeaders(url string, timeout time.Duration, headers map[string]string)
Every new option doubles your constructors. This is the constructor explosion problem, and Go has no built-in solution since it lacks optional parameters.
Quick Summary:
The functional options pattern uses function values to represent configuration. One constructor accepts variadic options: NewClient(url, WithTimeout(30*time.Second), WithRetries(5)). Adding new options doesn't break existing code. Self-documenting, composable, and used everywhere in production Go.
The Pattern
Here's how functional options work:
// 1. Define the option type
type ClientOption interface {
apply(c *Client)
}
// 2. Implement as an unexported function type
type clientOption func(c *Client)
func (o clientOption) apply(c *Client) {
o(c)
}
// 3. Create option constructors
func WithTimeout(d time.Duration) ClientOption {
return clientOption(func(c *Client) {
if d > 0 {
c.timeout = d
}
})
}
func WithHeaders(headers map[string]string) ClientOption {
return clientOption(func(c *Client) {
c.headers = headers
})
}
func WithRetries(count int) ClientOption {
return clientOption(func(c *Client) {
if count > 0 {
c.retries = count
}
})
}
// 4. Single constructor accepts variadic options
func NewClient(url string, opts ...ClientOption) *Client {
c := &Client{
url: url,
timeout: 30 * time.Second, // sensible defaults
retries: 3,
}
for _, opt := range opts {
opt.apply(c)
}
return c
}
Usage is clean and self-explanatory:
// Simple case — just use defaults
client := NewClient("https://api.example.com")
// With options — readable and explicit
client := NewClient("https://api.example.com",
WithTimeout(60 * time.Second),
WithHeaders(map[string]string{"Authorization": "Bearer token"}),
WithRetries(5),
)
Why This Works
Key Benefits:
- No constructor explosion — Add new options without touching existing code
- Self-documenting — Reading the call tells you exactly what's configured
- Safe defaults — Invalid values are ignored, not panicked on
- Composable — Group and reuse option sets
Safe Defaults
Each option validates its input:
func WithTimeout(d time.Duration) ClientOption {
return clientOption(func(c *Client) {
if d > 0 { // Reject invalid values
c.timeout = d
}
})
}
// Safe to pass anything:
NewClient("url", WithTimeout(-1 * time.Second)) // Uses default instead of crashing
Composable Options
You can group options and reuse them:
var devOptions = []ClientOption{
WithTimeout(5 * time.Second),
WithRetries(1),
}
var prodOptions = []ClientOption{
WithTimeout(30 * time.Second),
WithRetries(5),
}
// Usage
devClient := NewClient("https://dev.example.com", devOptions...)
prodClient := NewClient("https://prod.example.com", prodOptions...)
Real World Usage
The functional options pattern is everywhere in production Go. Libraries like gRPC-Go, go-redis, and zap all use it.
Common Use Cases:
- Database connections — pool sizes, timeouts, SSL modes
- HTTP clients — custom transports, retry policies
- Loggers — levels, outputs, formatters
- gRPC — security, interceptors, dial options
Common Pitfalls
Don't expose the implementation:
// Bad — can be called directly
type ClientOption func(*Client)
// Good — can only use option constructors
type clientOption func(*Client)
Always validate inputs:
// Bad — what if size is -5?
func WithPoolSize(size int) DBOption {
return dbOption(func(db *Database) {
db.poolSize = size
})
}
// Good — bounds checking
func WithPoolSize(size int) DBOption {
return dbOption(func(db *Database) {
if size > 0 && size <= 100 {
db.poolSize = size
}
})
}
Testing
Options are straightforward to unit test:
func TestWithTimeout(t *testing.T) {
client := NewClient("localhost", WithTimeout(15*time.Second))
if client.timeout != 15*time.Second {
t.Errorf("expected 15s, got %v", client.timeout)
}
}
func TestMultipleOptions(t *testing.T) {
client := NewClient("localhost",
WithTimeout(10*time.Second),
WithRetries(20),
)
if client.timeout != 10*time.Second || client.retries != 20 {
t.Fatal("options not applied correctly")
}
}
Summary
The functional options pattern feels strange at first, then becomes second nature. It's one of Go's most elegant solutions for configuration without optional parameters.
When to use it:
Building anything configurable—clients, servers, databases—where you want clean defaults, safe extension points, and readable construction.
Links: