IndexAdvanced Go PatternsPart 01

Functional Options

Constructors that stay clean as your config grows.

Jan 14, 20265 min readAPI DesignPart 01 of 11

You're writing a constructor for a Server. Today it needs a host and a port. Next month, a timeout. Then TLS config, a logger, max connections, a retry policy. How do you let callers configure all of that without making the common case painful and without breaking every existing call site each time you add a knob?

The functional options pattern is Go's idiomatic answer. You've seen it in the standard library and nearly every serious Go package. Here's how it works and when to reach for it.

The approaches that don't scale

A giant constructor breaks every caller each time you add a parameter, and reads terribly at the call site:

Go
// What does the third `true` mean? Who knows.
srv := NewServer("localhost", 8080, 30*time.Second, true, false, nil, 100)

A config struct is better, and sometimes the right call (more on that later). But it has rough edges: zero values are ambiguous (did the caller want Timeout: 0, or just not set it?), and there's nothing stopping invalid combinations.

Go
type Config struct {
    Host    string
    Port    int
    Timeout time.Duration
}
srv := NewServer(Config{Host: "localhost", Port: 8080})
// Timeout is 0 here — is that "no timeout" or "use the default"? Unclear.

The pattern

An option is a function that mutates the thing being configured. The constructor takes a variadic slice of them.

Go
type Server struct {
    host    string
    port    int
    timeout time.Duration
    logger  *slog.Logger
}

// Option configures a Server.
type Option func(*Server)

func WithPort(port int) Option {
    return func(s *Server) { s.port = port }
}

func WithTimeout(d time.Duration) Option {
    return func(s *Server) { s.timeout = d }
}

func WithLogger(l *slog.Logger) Option {
    return func(s *Server) { s.logger = l }
}

func NewServer(host string, opts ...Option) *Server {
    // Sensible defaults first.
    s := &Server{
        host:    host,
        port:    8080,
        timeout: 30 * time.Second,
        logger:  slog.Default(),
    }
    // Then let options override them.
    for _, opt := range opts {
        opt(s)
    }
    return s
}
defaultsport 8080timeout 30s WithPort(9090) WithTimeout(5s) WithLogger(l) *Serverconfigured& ready each option mutates one field; the loop applies them over the defaults
Options are composable funcs that mutate a config in turn

The call site is now self-documenting, and the common case is trivial:

Go
srv := NewServer("localhost")                              // all defaults
srv := NewServer("localhost", WithPort(9090))              // one override
srv := NewServer("localhost",
    WithPort(9090),
    WithTimeout(5*time.Second),
    WithLogger(myLogger),
)

Adding a new option later is a purely additive change — every existing call site keeps compiling. That's the whole payoff.

telescoping params functional options NewServer(host, port) NewServer(host, port, timeout) NewServer(host, port, timeout, tls, log) every change breaks callers NewServer(host, ...Option) signature never changes new options are additive
A telescoping signature breaks; a variadic options signature stays put

Why this beats the alternatives

  • Defaults are explicit and live in one place — the constructor. There's no "is zero the default or a real value?" ambiguity.
  • It's backward compatible by construction. New options never break callers.
  • Call sites read like English. WithTimeout(5*time.Second) says exactly what it does.
  • Required vs. optional is enforced by the signature. Required args are positional (host); optional ones are options. The type system makes you pass the required ones.

Patterns within the pattern

Validation and errors. Options can fail. Give the option a return value and have the constructor collect errors:

Go
type Option func(*Server) error

func WithPort(port int) Option {
    return func(s *Server) error {
        if port < 1 || port > 65535 {
            return fmt.Errorf("invalid port %d", port)
        }
        s.port = port
        return nil
    }
}

func NewServer(host string, opts ...Option) (*Server, error) {
    s := &Server{host: host, port: 8080}
    for _, opt := range opts {
        if err := opt(s); err != nil {
            return nil, fmt.Errorf("server option: %w", err)
        }
    }
    return s, nil
}

(See post 3 for why we wrap with %w.)

The option as an interface. For libraries that need options to carry state or be introspected, an interface works too. The standard library's gRPC bindings use this style:

Go
type Option interface {
    apply(*Server)
}

type optionFunc func(*Server)

func (f optionFunc) apply(s *Server) { f(s) }

func WithPort(port int) Option {
    return optionFunc(func(s *Server) { s.port = port })
}

This is more verbose but lets you do things like type-switch on options or define unexported options that external packages can't construct. Reach for it only when you need that power; the plain func version is lighter.

Grouping options. An option can apply several others, which is handy for presets:

Go
func WithProductionDefaults() Option {
    return func(s *Server) {
        s.timeout = 60 * time.Second
        s.logger = productionLogger()
    }
}

When not to use functional options

This pattern isn't free — it's more code than a struct, and it adds a small allocation per option. Skip it when:

  • Config is large and mostly required. If a caller must set 12 fields, a config struct is clearer than 12 With... calls. Parsing config from a file or env? A struct that maps to your config format is the natural fit.
  • The type is internal and you control every call site. You don't need backward-compatibility ceremony for a struct only your own package constructs — just add the field.
  • There are only one or two optional knobs that will never grow. A struct or even an extra parameter is fine.

A good rule of thumb: functional options earn their keep when the type is part of a public API and the set of options is expected to grow over time. That's exactly the situation they were designed for.

The takeaway

Functional options give you constructors with clean defaults, self-documenting call sites, and the freedom to add configuration later without breaking anyone. Use func(*T) for the simple case, add an error return when options can fail, and reach for the interface form only when you need introspection or unexported options. When config is large, required, or internal, a plain struct is still the better tool — don't cargo-cult the pattern where it doesn't pay.

The takeaway

Functional options earn their keep on public APIs whose configuration is expected to grow: defaults stay in one place, call sites read like English, and adding a knob never breaks an existing caller.