Archetechmes

Write mainly around backend systems, infrastructure and system programming.

Functional options in go are design patterns for configuring structs cleanly when there are many optional settings. Basically a function that carries a configuration change. It let the library to add configuration without continually changing the constructor shape.

One thing developers gets wrong about functional options is that they are not all about syntax, it solve an API-design problem. In terms of how do you design a constructor that can grow without becoming hard to use or breaking existing code.

The pattern isn’t automatically better just because it’s popular. They are most useful in areas of sensible defaults, configuration that will likely grow and a need to preserve backward compatibility.

Instead of passing 10 arguments to a constructor, you only pass functions that modify a configuration struct.

1. The normal approach

Client:

type Client struct {
    timeout int
    retries int
    baseURL string
}

You could create it like this:

func NewClient(timeout int, retries int, baseURL string) *Client {
    return &Client{
        timeout: timeout,
        retries: retries,
        baseURL: baseURL,
    }
}

Then:

client := NewClient(30, 3, "https://api.example.com")

This becomes annoying when you have many options.

2. Functional options

Instead, define a configuration:

type Config struct {
    timeout int
    retries int
    baseURL string
}

Then define an option type:

type Option func(*Config)

This means: Option is a function that receives a Config and modifies it.

Now create options:

func WithTimeout(timeout int) Option {
    return func(c *Config) {
        c.timeout = timeout
    }
}

func WithRetries(retries int) Option {
    return func(c *Config) {
        c.retries = retries
    }
}

func WithBaseURL(url string) Option {
    return func(c *Config) {
        c.baseURL = url
    }
}

The important part is this:

func WithTimeout(timeout int) Option {
    return func(c *Config) {
        c.timeout = timeout
    }
}

WithTimeout(30) doesn’t configure the client immediately.

It returns a function:

func(c *Config) {
    c.timeout = 30
}

That function can later be applied to the configuration.

The constructor:

func NewClient(options ...Option) *Client {
    config := Config{
        timeout: 10,
        retries: 1,
        baseURL: "https://api.example.com",
    }

    for _, option := range options {
        option(&config)
    }

    return &Client{
        timeout: config.timeout,
        retries: config.retries,
        baseURL: config.baseURL,
    }
}

Now you can write:

client := NewClient(
    WithTimeout(30),
    WithRetries(5),
    WithBaseURL("https://my-api.com"),
)
Create default Config

WithTimeout(30) modifies it

WithRetries(5) modifies it

WithBaseURL(...) modifies it

Create Client

It’s useful when you have optional configuration without creating a huge constructor. Options that modify nested configuration. Functional options don’t have to only modify the top-level fields.

type Config struct {
    HTTP HTTPConfig
}

type HTTPConfig struct {
    Timeout time.Duration
    KeepAlive bool
}

You can also do:

func WithHTTPTimeout(t time.Duration) Option {
    return func(c *Config) {
        c.HTTP.Timeout = t
    }
}

The option is still:

func(*Config)

But it can modify any part of the configuration tree.

This becomes useful when a system has subsystems:

Client
 ├── HTTP settings
 ├── TLS settings
 ├── Retry settings
 └── Logging settings

Functional options are usually build on top of a default configuration and Go’s zero-value philosophy makes that approach natural.

For example:

type Config struct {
    Retries int
    Timeout time.Duration
    Debug   bool
}

The zero values are:

Retries  = 0
Timeout  = 0
Debug    = false

Sometimes those are perfectly valid defaults.

For example:

func NewClient(options ...Option) *Client {
    config := Config{}

    for _, option := range options {
        option(&config)
    }

    return &Client{...}
}

But sometimes the zero value is not the desired default.

For example:

Timeout = 0

Might mean no timeout, while you actually want:

Timeout = 10 seconds

Then you explicitly establish defaults:

config := Config{
    Timeout: 10 * time.Second,
    Retries: 3,
}

The functional options then become overrides of those defaults:

Default configuration

apply optional overrides

validate final configuration

construct object

This is one of the cleanest models for this pattern.

The important part is:

zero value ≠ default value

A Go type can have a zero value, but your application’s desired default can be different.

For functional options, sensible defaults are important because they make the common case to stay simple:

client := NewClient()

While advanced users can opt into additional behavior:

client := NewClient(
    WithTimeout(30*time.Second),
    WithRetries(5),
)

So the pattern works best when most users can rely on good defaults and only specify the settings they actually need.

Also options can contain validation as well:

func WithRetries(n int) Option {
    return func(c *Config) {
        if n < 0 {
            n = 0
        }
        c.retries = n
    }
}

They can also configure complicated things, not just assign values:

func WithTLS(cert []byte) Option {
    return func(c *Config) {
        // configure TLS internally
    }
}

Validation is important when you have an option that can receive value that would make the resulting configuration invalid, unsafe or nonsensical.

So an option can be an extension point for configuration behavior. Functional options are not all about constructing a object, it’s about customizing a object in terms of how it should behave.

The important flow:

NewClient(...)

Create default Config

WithTimeout(30)

WithRetries(5)

WithBaseURL(...)

Return configured Client

The important line to understand:

type Option func(*Config)

It means that the option is simple, a function that receives a *config and changes it. It’s also important for us to know what the options does to the configuration.

Setter options primary purpose is to set or replace a specific configuration value.

Behavioral options primary purpose is to configure or change how the resulting object behaves, by applying logic or configuring multiple internal settings.

1. Setter options

Suppose we have:

type Config struct {
    timeout time.Duration
    retries int
}

A simple setter option changes one field:

type Option func(*Config)

func WithTimeout(t time.Duration) Option {
    return func(c *Config) {
        c.timeout = t
    }
}

func WithRetries(n int) Option {
    return func(c *Config) {
        c.retries = n
    }
}

When you write:

client := NewClient(
    WithTimeout(30*time.Second),
    WithRetries(5),
)

the options essentially do:

WithTimeout(30s)

c.timeout = 30s

WithRetries(5)

c.retries = 5

These are setter options because their main job is assigning configuration values.

2. Behavioral options

Now if you want to configure logging.

Rather than setting a primitive value:

c.logger = logger

The option can configure many things:

func WithLogger(logger Logger) Option {
    return func(c *Config) {
        c.logger = logger
        c.enableLogging = true
    }
}

Or adding middleware:

func WithMiddleware(m Middleware) Option {
    return func(c *Config) {
        c.middlewares = append(c.middlewares, m)
    }
}

The option isn’t simply saying:

middleware = X

It is changing how the client behaves:

WithMiddleware(...)

store middleware

Client uses middleware for requests

This is how behavioral option works.

A more obvious example.

Database client:

type Config struct {
    maxConnections int
    logger         Logger
    retryPolicy    RetryPolicy
}

A setter:

func WithMaxConnections(n int) Option {
    return func(c *Config) {
        c.maxConnections = n
    }
}

This simply changes data.

But:

func WithExponentialBackoff() Option {
    return func(c *Config) {
        c.retryPolicy = ExponentialBackoff{}
    }
}

Changes the strategy the client uses when requests fail.

So:

Setter option
    "Use 20 connections."

Behavioral option
    "Use exponential backoff when retrying."

The second one represents behavior, not only a value.

3. Behavioral options can hide complex configuration.

This is one of the important and main reasons to use functional options.

For example:

func WithTLS(cert []byte, key []byte) Option {
    return func(c *Config) {
        c.tlsConfig = &tls.Config{
            Certificates: []tls.Certificate{
                loadCertificate(cert, key),
            },
        }
    }
}

The caller doesn’t need to know how the internal TLS configuration is constructed.

You can just write:

client := NewClient(
    WithTLS(cert, key),
)

Internally:

WithTLS(...)

load certificate

create tls.Config

store it in Config

Client uses TLS

The functional option acts as an encapsulation boundary.

Don’t think of it as two completely different types. Both are still:

type Option func(*Config)

The difference is what the function accomplishes.

Setter option

Changes a configuration value

Behavioral option

Changes configuration logic,
strategy, or resulting object behavior

And there is a spectrum between them. An option can start as a simple setter and become more sophisticated as the implementation grow.

This is important to mention because it demonstrates that functional options aren’t only a replacement for constructor parameters, they can also helps in giving a controlled way to expose higher-level configuration behavior without exposing the internal implementation details.

One thing to remember is that don’t use it for everything, for example a normal constructor work wells when you have a smaller number of required values. The need for functional options only appear when you have many optional behavior.

Functional options works great with API evolution, you can start a library with only few configuration settings and acquire many more over time. It’s allow new capabilities to add without changing the basic construction model.

With that keeping in mind, functional options has it’s own problem. The first one is additional abstraction, what looks like a simple configuration call is actually based on function types, closures, pointers, variadic arguments, and a loop applying those functions. For a small object, that machinery can be considerably more complicated than the problem itself.

Another thing is configuration conflicts. You can write:

NewClient(
    WithTimeout(10*time.Second),
    WithTimeout(30*time.Second),
)

The library needs a defined rule for which one wins. Usually the last option wins, but this becomes more complicated when options affect different parts of the system or interact with each other.

A configuration struct exposes its fields directly, making it easy for an IDE to show what can be configured. With functional options, users need to discover functions such as WithTimeout, WithLogger, WithTransport, and so on. Good naming and documentation becomes important.

Another important limitation is that functional options are primarily suited for construction-time configuration. They are not a good replacement for runtime configuration management. If a server needs its logging level changed while it is running, an explicit method or concurrency-safe configuration mechanism may be more appropriate.

They are more well performed and have best utilization in public API knowing that the API will grow. These are more realistically better for HTTP clients, database client and networking libraries.