Functional Options Pattern
The functional options pattern configures complex types through a variadic list of Option functions passed to a constructor.
It replaces telescoping New overloads and mutable builder structs with composable, order-independent settings.
Summary
Functional options model each configuration knob as func(*T) applied inside New.
Callers pass only the settings they care about; the constructor applies defaults first, then runs each option.
The pattern is idiomatic for libraries and infrastructure clients where many orthogonal settings exist and binary compatibility matters.
It trades a small amount of boilerplate for APIs that stay readable as they grow.
Recipe
Quick-reference recipe card - copy-paste ready.
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 NewClient(opts ...Option) (*Client, error) {
c := &Client{timeout: 30 * time.Second}
for _, opt := range opts {
if err := opt(c); err != nil { return nil, err }
}
return c, nil
}When to reach for this:
- Exported types with several optional settings
- Library APIs where new options must not break existing call sites
- Constructors that need sensible defaults but allow overrides
- Settings that are independent (timeout, retries, TLS, logging)
Working Example
package weather
import (
"context"
"errors"
"fmt"
"net/http"
"time"
)
type Client struct {
base string
http *http.Client
apiKey string
}
type Option func(*Client) error
func WithBaseURL(url string) Option {
return func(c *Client) error {
if url == "" { return errors.New("weather: base URL required") }
c.base = url
return nil
}
}
func WithAPIKey(key string) Option {
return func(c *Client) error {
if key == "" { return errors.New("weather: API key required") }
c.apiKey = key
return nil
}
}
func WithHTTPClient(hc *http.Client) Option {
return func(c *Client) error {
if hc == nil { return errors.New("weather: http client required") }
c.http = hc
return nil
}
}
func New(opts ...Option) (*Client, error) {
c := &Client{
base: "https://api.weather.example",
http: &http.Client{Timeout: 10 * time.Second},
}
for _, opt := range opts {
if err := opt(c); err != nil {
return nil, err
}
}
if c.apiKey == "" {
return nil, errors.New("weather: API key required")
}
return c, nil
}
func (c *Client) Forecast(ctx context.Context, city string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/v1/forecast?city=%s", c.base, city), nil)
if err != nil { return "", err }
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.http.Do(req)
if err != nil { return "", err }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("weather: status %d", resp.StatusCode)
}
return "sunny", nil
}
func main() {
client, err := New(
WithAPIKey("dev-key"),
WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
)
if err != nil { panic(err) }
fmt.Println(client.Forecast(context.Background(), "Austin"))
}What this demonstrates:
- Defaults (
base,http.Clienttimeout) live insideNew - Each
With*function validates its own input Newenforces cross-field rules (API key required)- Callers compose options without knowing field layout
Deep Dive
How It Works
Optionis a function type closed over configuration valuesNewallocates the target, sets defaults, then appliesoptsin order- Options mutate unexported fields on the constructed type
- Export
With*helpers; keepOptionunexported when possible
Option Ordering and Conflicts
| Scenario | Behavior | Recommendation |
|---|---|---|
| Independent settings | Order does not matter | Document as order-independent |
| Last wins | Later option overwrites earlier | Document explicitly or reject duplicates |
| Mutually exclusive TLS modes | Invalid combination | Return error from second option or from New |
Variants
| Variant | Signature | Use When |
|---|---|---|
| Silent options | func(*T) | Simple settings, panic-free validation in New |
| Validating options | func(*T) error | Per-option validation failures |
| Functional options struct | Exported Config + New(Config) | All fields always provided together |
Go Notes
// Prefer unexported Option type at package scope.
type option func(*Server)
// Export constructors for discoverability.
func WithAddr(addr string) option { /* ... */ }- Do not export the struct fields you are protecting with options
- Return
( *T, error)fromNewwhen validation can fail - Use meaningful option names:
WithTimeout, notSetTimeout(mutation implied)
Gotchas
- Exported fields bypass options - If struct fields are exported, callers mutate state after
Newand invariants break. Fix: keep config fields unexported. - Order-sensitive options without docs - Two options touching the same field confuse callers. Fix: document last-wins or return an error on conflict.
- Options for two-field types -
New(a, b)is clearer thanNew(WithA(a), WithB(b))for trivial types. Fix: reserve options for 3+ orthogonal settings. - Nil pointer options -
WithHTTPClient(nil)panics on use. Fix: validate non-nil in the option function. - Breaking option semantics - Renaming
WithTTLto mean a different field breaks callers silently. Fix: add newWith*functions; deprecate old names across releases. - Testing every combination - Cartesian product of options explodes. Fix: table-test representative combos and validation failures.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Config struct parameter | All fields set together, few optional knobs | Many optional orthogonal settings |
| Builder struct with methods | Fluent API for internal DSLs | Public library API (harder to evolve) |
| Functional options | Library constructors with defaults | One or two required parameters only |
| Environment variables only | CLI tools, twelve-factor apps | Libraries consumed by other modules |
FAQs
Who popularized functional options in Go?
The pattern appears in early Go blog posts and is used widely in google.golang.org/grpc, OpenTelemetry, and stdlib-adjacent libraries.
It matches Go's preference for functions over inheritance.
Should Option be exported?
Usually no - export With* functions and keep type option func(*T) unexported so callers cannot forge options that bypass validation.
Can options run in any order?
Design independent options to commute.
When order matters, document it or detect conflicts and return errors.
How do I test options?
Table-test New with option subsets: defaults only, single override, invalid option, conflicting options.
Should options allocate?
Keep options cheap; defer heavy work (TLS cert loading) to New after all options apply, or lazy-init on first use.
Functional options vs Config struct?
Config structs group required fields; options shine when most fields have defaults and callers override a few.
How many options is too many?
There is no fixed limit - grpc has dozens.
Group related settings behind nested option helpers if names clutter godoc.
Can I use generics with options?
Go 1.18+ can type-parameterize helpers, but the classic Option func(*T) pattern remains the community default for constructors.
What about functional options for unexported types?
Return an interface or opaque type from New when the concrete struct must stay hidden.
Should New panic on bad options?
Return error from New or options for library code.
Reserve panic for programmer errors in internal packages only.
Related
- Go Patterns Basics - introductory option snippet
- Constructor Functions & Package APIs - New/Open pairing
- Go Idioms: Composition Over Inheritance - API design context
- Common Anti-Patterns in Go Codebases - over-engineered constructors
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify at build).