Golang Functional Options are Named Arguments on Steroids
Go has no named arguments, no optional parameters, and no default values. That leaves API designers with two awkward choices: long positional argument lists, or a public config struct that leaks every internal knob.
Functional options offer a third way. Required arguments stay explicit, and optional behavior is configured with small With... functions tacked onto the end of the call. The result reads like named arguments from Python or Ruby, but with first-class support for defaults, validation, alternate input forms, and backwards-compatible API growth.
What are functional options?
With named arguments, callers can set only the parameters they care about:
my_func(arg1, arg2, named_arg1=val1, named_arg2=val2)
In Go, each With... function returns another function that mutates private configuration:
type myFuncConfig struct {
namedArg1 string
namedArg2 bool
}
type MyFuncOption func(*myFuncConfig)
func WithNamedArg1(val string) MyFuncOption {
return func(c *myFuncConfig) {
c.namedArg1 = val
}
}
func WithNamedArg2(val bool) MyFuncOption {
return func(c *myFuncConfig) {
c.namedArg2 = val
}
}
func MyFunc(arg1, arg2 string, opts ...MyFuncOption) {
cfg := myFuncConfig{
namedArg1: "default value",
namedArg2: true,
}
for _, opt := range opts {
opt(&cfg)
}
// Use arg1, arg2, and cfg here.
}
The implementation is more verbose than a plain config struct, but the public API is compact and extensible.
Why functional options?
The usual alternative is a configuration struct. Config structs are simple, but zero values create ambiguity. In the example below, 0 means "use the default timeout", so callers can no longer pass 0 to disable the timeout:
type MyFuncConfig struct {
Timeout time.Duration
}
func MyFunc(cfg MyFuncConfig) {
if cfg.Timeout == 0 {
cfg.Timeout = time.Second
}
}
You can work around this with *time.Duration pointers or sentinel values, but each workaround leaks into every caller.
Config structs also get awkward when one setting can be expressed in multiple forms. For example, a directory could come from a path, a filesystem implementation, or an environment variable:
type MyFuncConfig struct {
// Which field should the caller use?
Dir string
DirFS fs.FS
DirEnv string
}
Functional options make each form explicit while keeping the internal representation private:
import (
"errors"
"io/fs"
"os"
"time"
)
type myFuncConfig struct {
timeout time.Duration
dir fs.FS
}
type MyFuncOption func(*myFuncConfig)
func WithTimeout(timeout time.Duration) MyFuncOption {
return func(c *myFuncConfig) {
c.timeout = timeout
}
}
func WithDir(dir string) MyFuncOption {
return func(c *myFuncConfig) {
c.dir = os.DirFS(dir)
}
}
func WithDirFS(dir fs.FS) MyFuncOption {
return func(c *myFuncConfig) {
c.dir = dir
}
}
func DirFromEnv(envName string) MyFuncOption {
return func(c *myFuncConfig) {
dir, ok := os.LookupEnv(envName)
if ok {
c.dir = os.DirFS(dir)
}
}
}
func MyFunc(opts ...MyFuncOption) error {
cfg := myFuncConfig{
timeout: time.Second,
}
for _, opt := range opts {
opt(&cfg)
}
if cfg.dir == nil {
return errors.New("dir is required")
}
// Use cfg here.
return nil
}
The call site is easy to scan:
err := MyFunc(
WithTimeout(0), // Disable the timeout.
WithDir("/home"), // Use /home by default.
DirFromEnv("DIR"), // Override the directory when DIR is set.
)
Bool options
After getting used to functional options, you may ask which API is better:
func WithEnabled(on bool) MyFuncOption
// Or:
func Enable() MyFuncOption
func Disable() MyFuncOption
The boolean form is usually more convenient when the value comes from configuration, flags, or environment:
MyFunc(WithEnabled(env == "prod"))
Without the boolean form, callers have to build an option slice:
var opts []MyFuncOption
if env == "prod" {
opts = append(opts, Enable())
} else {
opts = append(opts, Disable())
}
MyFunc(opts...)
You can still provide both forms when readability matters:
func Enable() MyFuncOption {
return WithEnabled(true)
}
func Disable() MyFuncOption {
return WithEnabled(false)
}
Options that can fail
Some options need to validate input or load data from disk. Returning an error from inside a func(*config) is impossible, so a common variant lets options return an error:
type Option func(*config) error
func WithCertFile(path string) Option {
return func(c *config) error {
cert, err := tls.LoadX509KeyPair(path, path)
if err != nil {
return fmt.Errorf("WithCertFile(%q): %w", path, err)
}
c.cert = &cert
return nil
}
}
func New(opts ...Option) (*Client, error) {
cfg := defaultConfig()
for _, opt := range opts {
if err := opt(&cfg); err != nil {
return nil, err
}
}
return &Client{cfg: cfg}, nil
}
If only a few options need validation, a simpler trick is to stash the error on the config and check it once after applying all options. Pick whichever style you start with and stay consistent — mixing the two in the same package is confusing.
Reusing options for different purposes
Functional options do not have to mutate one concrete struct. You can define options against a small interface and reuse them across related types.
For example, a Redis client and a Redis Cluster client can share the same option type:
type Option func(configurator)
type configurator interface {
setAddr(addr string)
setTimeout(timeout time.Duration)
}
func WithAddr(addr string) Option {
return func(c configurator) {
c.setAddr(addr)
}
}
func WithTimeout(timeout time.Duration) Option {
return func(c configurator) {
c.setTimeout(timeout)
}
}
A single-node client can treat WithAddr as a replacement:
type Client struct {
addr string
timeout time.Duration
}
func (c *Client) setAddr(addr string) {
c.addr = addr
}
func (c *Client) setTimeout(timeout time.Duration) {
c.timeout = timeout
}
func NewClient(opts ...Option) *Client {
c := new(Client)
for _, opt := range opts {
opt(c)
}
return c
}
And a cluster client can treat repeated WithAddr calls as a list:
type ClusterClient struct {
addrs []string
timeout time.Duration
}
func (c *ClusterClient) setAddr(addr string) {
c.addrs = append(c.addrs, addr)
}
func (c *ClusterClient) setTimeout(timeout time.Duration) {
c.timeout = timeout
}
func NewClusterClient(opts ...Option) *ClusterClient {
c := new(ClusterClient)
for _, opt := range opts {
opt(c)
}
return c
}
This pattern is useful when several constructors should share common options without sharing the same internal config struct.
Grouping options by categories
Functional options can produce many exported With... functions. pkg.go.dev already groups those functions under the option type:
type Option func(*config)
func WithDir(dir string) Option
func WithDirFS(dir fs.FS) Option
func DirFromEnv(envName string) Option
For larger APIs, you can create subgroups with interfaces. For example, an OpenTelemetry client can have common options, tracing-only options, and metrics-only options:
type config struct {
dsn string
tracingEnabled bool
metricsEnabled bool
}
type Option interface {
apply(*config)
}
type TracingOption interface {
Option
tracingOption()
}
type MetricsOption interface {
Option
metricsOption()
}
func ConfigureOpenTelemetry(opts ...Option) *config {
cfg := new(config)
for _, opt := range opts {
opt.apply(cfg)
}
return cfg
}
Marker methods such as tracingOption and metricsOption do not need behavior. They only let you return a more specific option type:
type dsnOption string
func (o dsnOption) apply(c *config) {
c.dsn = string(o)
}
func WithDSN(dsn string) Option {
return dsnOption(dsn)
}
type tracingEnabledOption bool
func (o tracingEnabledOption) apply(c *config) {
c.tracingEnabled = bool(o)
}
func (o tracingEnabledOption) tracingOption() {}
func WithTracingEnabled(on bool) TracingOption {
return tracingEnabledOption(on)
}
type metricsEnabledOption bool
func (o metricsEnabledOption) apply(c *config) {
c.metricsEnabled = bool(o)
}
func (o metricsEnabledOption) metricsOption() {}
func WithMetricsEnabled(on bool) MetricsOption {
return metricsEnabledOption(on)
}
As a result, documentation shows three clear groups:
type Option
func WithDSN(dsn string) Option
type TracingOption
func WithTracingEnabled(on bool) TracingOption
type MetricsOption
func WithMetricsEnabled(on bool) MetricsOption
When not to use functional options
Functional options shine in constructors and setup functions with optional behavior, but they are not a universal answer.
- Reach for a config struct when callers need to serialize, clone, diff, or inspect configuration — for example, when configuration is loaded from YAML or JSON.
- Reach for plain arguments when the function only takes a couple of required parameters. Wrapping two arguments in
With...calls adds noise without payoff. - Reach for a builder when construction has a clear ordering or staged state (
NewQuery().Where(...).Limit(...).Build()), since options run as an unordered set.
Whichever style you pick, document the rule for conflicting options — usually "last option wins" — or return an error from the constructor when two options contradict each other.
Wrapping up
The first writeups of functional options go back to 2014, but the pattern is still underused in modern Go APIs. If you maintain a library, consider it the next time you are tempted to add a fourth positional argument or expose an internal config struct — your callers will thank you.
You may also be interested in: