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.

6 min read
golangdesign-patterns

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: