Why do we need it?

In the Go language we can define a struct with a bunch of fields attached to it. We can make them exported (public) or unexported (visible only for a package). One would think that we can just make a field public and allow the clients (callers of our package) to set values of the field. Sounds fine. But what if we only allow for this field (let’s say port) to be in certain range (from 8080 to 8090). We have to create a factory function that will create an instance of the struct, validate the passed age value and initialize the field with the value. Now, when the project grows, we have to add new fields, new restrictions. The factory function arguments grow, the signature changes, the api breaks. That is not optimal and tidieous.

Example of how the struct can grow and factory function signature can change over a couple of updates.

// version: v1
func NewServer(port int) *Server
(...)
// version: v4
func NewServer(port int, timeout time.Duration, cert string, retries int) *Server

“With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.”, Hyrum’s Law

Is there a more idiomatic approach?

Functional Options Pattern with Plain Functions

To solve these issues, we can use the Functional Options patttern. With this pattern we don’t break existing function api. The factory function always looks the same. The caller operates on a sensible set of default values and only applies what it wants to change. The factory function validates the input and sets the values on the struct.

type Option func(*Server)

func NewServer(opts ...Option) *Server {
    // the function set sensible defaults:
    s := &Server{
        port:    8080,          
        timeout: 30 * time.Second,
    }
    // the function applies all passed option functions...
    for _, opt := range opts {
        opt(s)
    }
    return s
}

// This function handles setting of the "port" field
func WithPort(p int) Option {
    return func(s *Server) { s.port = p }
}

// This function handles setting of the "timeout" field
func WithTimeout(d time.Duration) Option {
    return func(s *Server) { s.timeout = d }
}

// Client wants to use server with defaults
s1 := NewServer()

// Client wants to use server with changed port
s2 := NewServer(WithPort(9090))

If any validation is needed on these fields, they will be put into respective functions.

An option is a deferred mutation. Instead of passing data to a constructor, you pass behaviors — small functions that each know how to configure one thing. The constructor just runs them in order.

Why is this approach better?

  • Zero breaking changes: Adding a new option is a new exported function. Existing callers compile and behave identically — they just don’t use the new option yet.
  • Defaults live in one place: The constructor sets up the struct before applying options. Callers never need to know what zero values mean — they only override what they want.
  • Self-documenting call sites: WithTimeout(10 * time.Second) is unambiguous. No positional confusion, no magic booleans, no looking up the signature to understand the fifth argument.
  • Composable and reusable: Options are just values. You can collect them, store them, pass them around, and build higher-level presets from combinations of lower-level ones.

The pattern was popularized by Dave Cheney 1 and Rob Pike 2, and is now idiomatic in major Go libraries.

The core limitation of plain functions is that once you wrap logic in a func(*Server), it becomes an opaque blob — all options share the same type, so you lose the ability to distinguish, inspect, or reason about them. You cannot make introspection (inspect their type), conflict detection (duplicate options passed), comparison (between options), serialization (plain functions can’t be marshaled to JSON), can’t implement multiple interfaces (plain functions can do only one thing), limited visibility (in godoc).

If any of these limitations are an issue within Your project, follow to the next section.

Functional Options Pattern with Structs

// 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