Functional Options
Functional Options
A constructor with three or more optional settings is a maintenance problem waiting to happen. The functional options pattern solves it: an option is a function that writes one setting into a private config struct. The constructor accepts ...Option. Callers pick what they need; unset fields stay at their zero value. Adding a new option never touches existing call sites.
Mental model
type config struct {
name string
maxTickets int
}
type Option func(*config)
func NewDesk(opts ...Option) *Desk {
cfg := &config{} // all fields at zero
for _, o := range opts {
o(cfg) // each option writes one knob
}
return &Desk{cfg: cfg}
}Option is just a function type. WithName("cashier") returns a closure that sets cfg.name. NewDesk() calls every closure in the order the caller passed them. The signature of NewDesk never changes — new knobs are new option functions, nothing more.
Worked examples
Case 1: Basic options — zero, one, and two
Save as desk_basic.go. NewDesk accepts any number of options. Calling it with none gives sensible zero-value defaults. Calling it with options overrides exactly the fields the caller cares about.
// desk_basic.go
package main
import "fmt"
type config struct {
name string
maxTickets int
}
type Option func(*config)
func WithName(s string) Option {
return func(cfg *config) {
cfg.name = s
}
}
func WithMaxTickets(n int) Option {
return func(cfg *config) {
cfg.maxTickets = n
}
}
type Desk struct {
cfg config
}
func NewDesk(opts ...Option) *Desk {
cfg := config{
name: "default",
maxTickets: 100,
}
for _, o := range opts {
o(&cfg)
}
return &Desk{cfg: cfg}
}
func (d *Desk) String() string {
return fmt.Sprintf("Desk{name:%q maxTickets:%d}", d.cfg.name, d.cfg.maxTickets)
}
func main() {
// zero options — built-in defaults apply
d0 := NewDesk()
fmt.Println(d0)
// one option — only name changes
d1 := NewDesk(WithName("reception"))
fmt.Println(d1)
// two options — both fields overridden
d2 := NewDesk(WithName("cashier"), WithMaxTickets(50))
fmt.Println(d2)
}Run:
go run desk_basic.goOutput:
Desk{name:"default" maxTickets:100}
Desk{name:"reception" maxTickets:100}
Desk{name:"cashier" maxTickets:50}
Each call site is independent. Neither d0 nor d1 needs to know that maxTickets exists.
Case 2: Options that validate
Sometimes an option must reject bad input. Change Option to func(*config) error. The constructor collects every error with errors.Join and returns one combined error. The caller still passes a clean list of option calls; the error surfaces at NewDesk, not buried inside a setter.
Save as desk_validated.go:
// desk_validated.go
package main
import (
"errors"
"fmt"
"log/slog"
"os"
)
type config struct {
name string
maxTickets int
queueDepth int
}
type Option func(*config) error
func WithName(s string) Option {
return func(cfg *config) error {
if s == "" {
return errors.New("desk name must not be empty")
}
cfg.name = s
return nil
}
}
func WithMaxTickets(n int) Option {
return func(cfg *config) error {
if n <= 0 {
return fmt.Errorf("maxTickets must be positive, got %d", n)
}
cfg.maxTickets = n
return nil
}
}
func WithQueueDepth(n int) Option {
return func(cfg *config) error {
if n < 0 {
return fmt.Errorf("queueDepth must be non-negative, got %d", n)
}
cfg.queueDepth = n
return nil
}
}
type Desk struct {
cfg config
}
func NewDesk(opts ...Option) (*Desk, error) {
cfg := config{
name: "default",
maxTickets: 100,
queueDepth: 10,
}
var errs []error
for _, o := range opts {
if err := o(&cfg); err != nil {
errs = append(errs, err)
}
}
if err := errors.Join(errs...); err != nil {
return nil, err
}
return &Desk{cfg: cfg}, nil
}
func (d *Desk) String() string {
return fmt.Sprintf("Desk{name:%q maxTickets:%d queueDepth:%d}",
d.cfg.name, d.cfg.maxTickets, d.cfg.queueDepth)
}
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
good, err := NewDesk(WithName("billing"), WithMaxTickets(40), WithQueueDepth(5))
if err != nil {
logger.Error("bad config", "err", err)
os.Exit(1)
}
fmt.Println(good)
// pass two bad options at once — both errors surface together
_, err = NewDesk(WithName(""), WithMaxTickets(-3))
if err != nil {
logger.Error("bad config", "err", err)
}
}Run:
go run desk_validated.goOutput:
Desk{name:"billing" maxTickets:40 queueDepth:5}
time=... level=ERROR msg="bad config" err="desk name must not be empty\nmaxTickets must be positive, got -3"
errors.Join collects all failures in one pass. The caller sees the full list, not just the first error.
Case 3: Composed options — a shorthand for test setup
A function can return []Option and apply them as a batch. WithDefaults is not a single tweak — it is a curated starting point. Apply it first, then let the caller override specific fields.
Save as desk_compose.go:
// desk_compose.go
package main
import (
"errors"
"fmt"
)
type config struct {
name string
maxTickets int
queueDepth int
verbose bool
}
type Option func(*config) error
func WithName(s string) Option {
return func(cfg *config) error {
if s == "" {
return errors.New("desk name must not be empty")
}
cfg.name = s
return nil
}
}
func WithMaxTickets(n int) Option {
return func(cfg *config) error {
if n <= 0 {
return fmt.Errorf("maxTickets must be positive, got %d", n)
}
cfg.maxTickets = n
return nil
}
}
func WithQueueDepth(n int) Option {
return func(cfg *config) error {
cfg.queueDepth = n
return nil
}
}
func WithVerbose() Option {
return func(cfg *config) error {
cfg.verbose = true
return nil
}
}
// WithDefaults returns a standard set of options for test environments.
// Callers may append their own options to override individual fields.
func WithDefaults() []Option {
return []Option{
WithName("test-desk"),
WithMaxTickets(10),
WithQueueDepth(2),
WithVerbose(),
}
}
type Desk struct {
cfg config
}
func NewDesk(opts ...Option) (*Desk, error) {
cfg := config{}
var errs []error
for _, o := range opts {
if err := o(&cfg); err != nil {
errs = append(errs, err)
}
}
if err := errors.Join(errs...); err != nil {
return nil, err
}
return &Desk{cfg: cfg}, nil
}
func (d *Desk) String() string {
return fmt.Sprintf("Desk{name:%q max:%d queue:%d verbose:%v}",
d.cfg.name, d.cfg.maxTickets, d.cfg.queueDepth, d.cfg.verbose)
}
func main() {
// Test helper: start from defaults, override one field.
opts := append(WithDefaults(), WithMaxTickets(5))
d, err := NewDesk(opts...)
if err != nil {
panic(err)
}
fmt.Println("from defaults:", d)
// Production desk: explicit options, no defaults.
prod, err := NewDesk(WithName("billing"), WithMaxTickets(200), WithQueueDepth(50))
if err != nil {
panic(err)
}
fmt.Println("production: ", prod)
}Run:
go run desk_compose.goOutput:
from defaults: Desk{name:"test-desk" max:5 queue:2 verbose:true}
production: Desk{name:"billing" max:200 queue:50 verbose:false}
WithDefaults() returns a plain slice — not a special type. append combines it with extra options. The last write wins: WithMaxTickets(5) overwrites the 10 set by WithDefaults.
The trap
A long parameter list breaks callers every time you add a field.
// desk_trap.go — do NOT copy this pattern
package main
import (
"fmt"
"time"
)
type Desk struct {
name string
max int
verbose bool
timeout time.Duration
}
// Adding a fifth parameter here forces edits at every call site.
func NewDesk(name string, max int, verbose bool, timeout time.Duration) *Desk {
return &Desk{name: name, max: max, verbose: verbose, timeout: timeout}
}
func main() {
d := NewDesk("billing", 100, false, 30*time.Second)
fmt.Printf("%+v\n", d)
}Run:
go run desk_trap.goOutput:
&{name:billing max:100 verbose:false timeout:30s}
The program runs. The problem is the day you add a fifth parameter — retryLimit int. Every single call site fails to compile. In a large codebase that might mean dozens of files. With functional options, adding WithRetryLimit is a two-line addition and touches zero existing callers.
Save the corrected version as desk_fixed.go:
// desk_fixed.go
package main
import (
"fmt"
"time"
)
type config struct {
name string
max int
verbose bool
timeout time.Duration
retryLimit int // added later — zero call sites changed
}
type Option func(*config)
func WithName(s string) Option { return func(c *config) { c.name = s } }
func WithMax(n int) Option { return func(c *config) { c.max = n } }
func WithVerbose() Option { return func(c *config) { c.verbose = true } }
func WithTimeout(d time.Duration) Option { return func(c *config) { c.timeout = d } }
func WithRetryLimit(n int) Option { return func(c *config) { c.retryLimit = n } }
type Desk struct{ cfg config }
func NewDesk(opts ...Option) *Desk {
cfg := config{max: 100, timeout: 30 * time.Second}
for _, o := range opts {
o(&cfg)
}
return &Desk{cfg: cfg}
}
func (d *Desk) String() string {
return fmt.Sprintf("Desk{name:%q max:%d verbose:%v timeout:%v retryLimit:%d}",
d.cfg.name, d.cfg.max, d.cfg.verbose, d.cfg.timeout, d.cfg.retryLimit)
}
func main() {
// existing call site — unchanged despite adding retryLimit
d1 := NewDesk(WithName("billing"), WithMax(100), WithTimeout(30*time.Second))
fmt.Println(d1)
// new call site that uses the new option
d2 := NewDesk(WithName("express"), WithRetryLimit(3))
fmt.Println(d2)
}Run:
go run desk_fixed.goOutput:
Desk{name:"billing" max:100 verbose:false timeout:30s retryLimit:0}
Desk{name:"express" max:100 verbose:false timeout:30s retryLimit:3}
d1 was written before WithRetryLimit existed. It compiled then; it compiles now. No change required.
The boring rule
- Use
func(*config)options when a constructor has three or more optional knobs. - Use
func(*config) erroroptions when any knob has invalid states that must be caught at construction time. - Use a plain struct literal when all fields are required — there is nothing to be optional about and the struct is self-documenting.
- Put option functions in the same package as the type. They are the public API for configuration.
- Name options
WithX, neverSetX.Setimplies a method that mutates a live value;Withimplies configuration before creation. - Return
[]Optionfrom helper functions (likeWithDefaults) rather than a custom combinator type — plain slices compose withappend.
Try this
- In
desk_basic.go, addWithVerbose() Optionthat sets averbose boolfield onconfig. Print it inString(). CallNewDesk(WithVerbose())and confirm the field appears. - In
desk_validated.go, add cross-field validation: reject aqueueDepthgreater thanmaxTickets. You cannot do this inside a single option because both values must be set first. Add avalidate(cfg config) errorfunction called inNewDeskafter the option loop. - In
desk_compose.go, writeWithProductionDefaults() []Optionthat setsmaxTickets: 500,queueDepth: 100, andverbose: false. CallNewDeskonce with production defaults and once with test defaults. Print both desks. - Starting from
desk_fixed.go, changeWithTimeoutto return an error when the duration is shorter than one second. ChangeNewDeskto return(*Desk, error). Updatemainto handle the error and try passingWithTimeout(0).