Functional Options

Updated

September 13, 2026

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

&{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.go

Output:

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) error options 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, never SetX. Set implies a method that mutates a live value; With implies configuration before creation.
  • Return []Option from helper functions (like WithDefaults) rather than a custom combinator type — plain slices compose with append.

Try this

  1. In desk_basic.go, add WithVerbose() Option that sets a verbose bool field on config. Print it in String(). Call NewDesk(WithVerbose()) and confirm the field appears.
  2. In desk_validated.go, add cross-field validation: reject a queueDepth greater than maxTickets. You cannot do this inside a single option because both values must be set first. Add a validate(cfg config) error function called in NewDesk after the option loop.
  3. In desk_compose.go, write WithProductionDefaults() []Option that sets maxTickets: 500, queueDepth: 100, and verbose: false. Call NewDesk once with production defaults and once with test defaults. Print both desks.
  4. Starting from desk_fixed.go, change WithTimeout to return an error when the duration is shorter than one second. Change NewDesk to return (*Desk, error). Update main to handle the error and try passing WithTimeout(0).