Functional Options Pattern with Structs

In the previous article 1 we looked into the functional options with plain functions. That solution has few drawbacks. This time we will look into advanced pattern that will solve these issues. This pattern is most commonly used in big projects as it gives ability to introspect its inner workings.

Let us consider this code:

// The interface every option must satisfy
type Option interface {
    apply(*Server)
}

// Each option is a struct, that implements the Option interface
type timeoutOption struct{ d time.Duration }
func (o timeoutOption) apply(s *Server) { s.timeout = o.d }

// Each option is a struct, that implements the Option interface
type portOption struct{ port int }
func (o portOption) apply(s *Server) { s.port = o.port }

// Exported functions 
func WithTimeout(d time.Duration) Option { return timeoutOption{d} }
func WithPort(p int) Option              { return portOption{p} }

func NewServer(opts ...Option) *Server {
    s := &Server{port: 8080, timeout: 30 * time.Second}
    for _, opt := range opts {
        opt.apply(s)
    }
    return s
}

// Client call remains unchanged
srv := NewServer(
    WithPort(443),
    WithTimeout(10 * time.Second),
)

The power shows up in what you can do with those options.

Introspection: You can log which options were applied, detect conflicting options, or build a debug dump of the configuration. With structs, %T gives you the type name; with functions you get a memory address.

for _, opt := range opts {
    log.Printf("applying: %T", opt) 
    // output: "applying: timeoutOption"
    // output: "applying: portOption"
    opt.apply(s)
}

Conflict Detection: If a caller passes WithTimeout twice, or passes both WithInsecure and WithTLS, you can catch that. Struct-based options have distinct types, so you can iterate and enforce “only one of these kinds.”

seen := map[reflect.Type]bool{}
for _, opt := range opts {
    t := reflect.TypeOf(opt)
    if seen[t] {
        return nil, fmt.Errorf("duplicate option: %T", opt)
    }
    seen[t] = true
    opt.apply(s)
}

Now passing WithTimeout twice is a caught error, not a silent value change.

Validation: Options that check themselves. A struct can implement a second interface alongside function apply.

type Validator interface {
    validate() error
}

type timeoutOption struct{ d time.Duration }
func (o timeoutOption) apply(s *Server)  { s.timeout = o.d }
func (o timeoutOption) validate() error  {
    if o.d <= 0 {
        return fmt.Errorf("timeout must be positive, got %v", o.d)
    }
    return nil
}

// In the constructor
for _, opt := range opts {
    if v, ok := opt.(Validator); ok {
        if err := v.validate(); err != nil {
            return nil, err
        }
    }
    opt.apply(s)
}

A struct can satisfy apply() and validate() error and fmt.Stringer simultaneously — letting you build validation, documentation, and observability into the option type itself.

Testability: Options are comparable values.

opts := []Option{WithTimeout(5 * time.Second), WithPort(443)}

// This works because structs are comparable 
assert.Contains(t, opts, timeoutOption{5 * time.Second})

With plain functions, this is impossible — functions aren’t comparable in Go.

Serialization:

type timeoutOption struct{ D time.Duration }

func (o timeoutOption) MarshalJSON() ([]byte, error) {
    return json.Marshal(map[string]any{"type": "timeout", "value": o.D.String()})
}

Now you can persist, transmit, or audit which options were configured — impossible with closures.

Reach for this pattern when your library is public, long-lived, or used at scale — where you need observability into what was configured, protection against misuse, or per-option validation. For internal or simple APIs, plain functions are still the right default. The struct approach earns its boilerplate only when that extra control genuinely matters.

“Functional options let you write APIs that can grow over time. They enable the default use case to be the simplest. They provide meaningful configuration parameters.” Dave Cheney

Copyright © 2026 by Michal Przybylowicz