Functional Options Pattern: When It Helps and When It Doesn't
Oct 2026 · 4 min read
Functional options keep the constructor stable as optional configuration grows, while providing defaults and validation in one place. This pattern suits APIs with many independent options, but only adds indirection if configuration is minimal or all fields are required.
Full code: https://github.com/Sch39/go-series
Problem
Constructors with many parameters easily produce calls that are hard to read and fragile when new options are added. A config struct solves this, but makes the caller responsible for defaults and for distinguishing empty values from deliberately chosen values.
Outcome
Readers can:
Design constructors with consistent defaults and independent options.
Validate configuration when options are applied.
Determine when a plain constructor or config struct is simpler.
Implementation
Folder Structure
go-series/G01-09-functional-options/
├── go.mod
└── client/
├── client.go
└── client_test.goSetup
Initialize a single module (run from the go-series/ folder):
mkdir -p G01-09-functional-options
cd G01-09-functional-options
go mod init github.com/sch39/go-series/g0109/functionaloptionsCode Blocks
Block 1: Variadic constructor — centralized defaults
go-series/G01-09-functional-options/client/client.go:
package client
import (
"errors"
"time"
)
type Client struct {
Endpoint string
Timeout time.Duration
Retries int
}
type Option func(*Client) error
func New(options ...Option) (*Client, error) {
client := &Client{
Endpoint: "https://api.example.com",
Timeout: 5 * time.Second,
Retries: 2,
}
for _, option := range options {
if err := option(client); err != nil {
return nil, err
}
}
return client, nil
}Goal: Creating a constructor without options still produces a client with valid defaults.
Block 2: Option function — validated configuration
go-series/G01-09-functional-options/client/client.go:
func WithTimeout(timeout time.Duration) Option {
return func(client *Client) error {
if timeout <= 0 {
return errors.New("timeout must be positive")
}
client.Timeout = timeout
return nil
}
}
func WithRetries(retries int) Option {
return func(client *Client) error {
if retries < 0 {
return errors.New("retries must not be negative")
}
client.Retries = retries
return nil
}
}Goal: Package the mutation and validation of each configuration item as an Option value.
Block 3: Closure — options capture caller arguments
go-series/G01-09-functional-options/client/client.go:
func WithEndpoint(endpoint string) Option {
return func(client *Client) error {
if endpoint == "" {
return errors.New("endpoint must not be empty")
}
client.Endpoint = endpoint
return nil
}
}Goal: Capture the endpoint value in a closure until the constructor applies that option.
Test Blocks
Test 1: Constructor without options uses defaults
go-series/G01-09-functional-options/client/client_test.go:
package client_test
import (
"testing"
"time"
"github.com/sch39/go-series/g0109/functionaloptions/client"
)
func TestNewUsesDefaults(t *testing.T) {
got, err := client.New()
if err != nil {
t.Fatalf("New returned error: %v", err)
}
t.Logf("endpoint=%s timeout=%s retries=%d", got.Endpoint, got.Timeout, got.Retries)
if got.Endpoint != "https://api.example.com" || got.Timeout != 5*time.Second || got.Retries != 2 {
t.Errorf("New defaults = %+v", got)
}
}Goal: Prove that variadic options may be empty and defaults are still applied.
Test 2: Options are applied in order
go-series/G01-09-functional-options/client/client_test.go:
func TestOptionsOverrideInOrder(t *testing.T) {
got, err := client.New(
client.WithEndpoint("https://internal.example.com"),
client.WithTimeout(2*time.Second),
client.WithRetries(4),
client.WithRetries(1),
)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
t.Logf("endpoint=%s timeout=%s retries=%d", got.Endpoint, got.Timeout, got.Retries)
if got.Endpoint != "https://internal.example.com" || got.Timeout != 2*time.Second || got.Retries != 1 {
t.Errorf("configured client = %+v", got)
}
}Goal: Prove that options run in sequence so a later option can override a previous value.
Test 3: An invalid option fails construction
go-series/G01-09-functional-options/client/client_test.go:
func TestInvalidOptionStopsConstruction(t *testing.T) {
got, err := client.New(client.WithTimeout(0))
t.Logf("client=%v error=%v", got, err)
if err == nil {
t.Fatal("New returned nil error, want validation error")
}
if got != nil {
t.Errorf("New returned client %v, want nil", got)
}
}Goal: Prove that a validation error stops the constructor without returning a partially configured object.
Run Test
Run from the go-series/G01-09-functional-options/ folder:
go test -count=1 -v ./...Output:
=== RUN TestNewUsesDefaults
client_test.go:15: endpoint=https://api.example.com timeout=5s retries=2
--- PASS: TestNewUsesDefaults (0.00s)
=== RUN TestOptionsOverrideInOrder
client_test.go:32: endpoint=https://internal.example.com timeout=2s retries=1
--- PASS: TestOptionsOverrideInOrder (0.00s)
=== RUN TestInvalidOptionStopsConstruction
client_test.go:41: client=<nil> error=timeout must be positive
--- PASS: TestInvalidOptionStopsConstruction (0.00s)
PASS
ok github.com/sch39/go-series/g0109/functionaloptions/client 6.740sWhy It Works
Variadic parameter: The options ...Option parameter is received as a []Option slice inside New. Callers may send zero or more options without changing the constructor signature when new options are added.
Function value and closure: Option is a named function type. Functions such as WithTimeout return a closure that stores the caller's argument, and that closure then receives a pointer to Client when the constructor is ready to apply it.
Defaults then overrides: The constructor builds valid state first, then applies options in order. Because each option mutates the same object, a later option can override a field already set by an earlier option.
Edge Cases
The same option repeated: Order becomes part of the API contract; in this implementation the value from the last option wins. Document that rule or reject duplicates if order must not affect the result.
Nil option: Callers can technically pass nil as an Option; calling it will panic. The API can reject nil options if nil may come from dynamic composition.
Partial mutation before an error: Earlier options may already have modified the object before a later option fails. Returning nil, err prevents the caller from using a partially configured object, but external side effects inside options should still be avoided.
Trade-offs
Strategy | Pro | Con |
|---|---|---|
Functional options | Centralized defaults, extensible, expressive calls | More functions and indirection |
Config struct | All configuration is visible as data | Zero values and defaults can be ambiguous |
Plain constructor parameters | Simple for a few required parameters | Hard to read and change as options grow |
Best practice: Use functional options when there are many optional configuration items with stable defaults. Choose plain parameters for a few required values, or a config struct when configuration needs to be stored, serialized, and validated as data.
Gotchas I Encountered
Two
WithRetriescalls applied in sequence and the output showedretries=1; the implementation does not automatically detect duplicate options.WithTimeout(0)producedclient=<nil> error=timeout must be positive, so the constructor does not expose an object that was mutated before the error.
Production Checklist
Is the configuration truly optional? Keep required parameters as constructor arguments.
Are defaults valid without options? Initialize everything before the options loop.
Can options fail? Return an error and do not expose a partial object.
Do option order or duplicates matter? Document it or validate explicitly.
Are options free of external side effects? Restrict options to changing the object's configuration.
References
Effective Go, Variadic parameters: https://go.dev/doc/effective_go#passing
The Go Programming Language Specification, Function types: https://go.dev/ref/spec#Function_types
The Go Programming Language Specification, Function literals: https://go.dev/ref/spec#Function_literals
Effective Go, Constructors and composite literals: https://go.dev/doc/effective_go#composite_literals