| name | go-functional-options |
| description | Use when designing a Go constructor or factory with 3+ optional parameters, or an API expected to grow new options over time. Covers the canonical Option interface pattern with unexported apply method, With* constructors, default values, and the interface-vs-closure tradeoff. Apply proactively when reviewing a New* function that takes many settings, even if the user didn't ask about functional options. Does not cover general function design (see go-functions). |
| user-invocable | false |
| license | MIT |
| compatibility | Designed for Claude Code or similar AI coding agents. Plain Go (any supported version). |
| metadata | {"author":"muratmirgun","version":"0.1.0","openclaw":{"emoji":"⚙️","homepage":"https://github.com/muratmirgun/gophers","requires":{"bins":["go"]},"install":[]}} |
| allowed-tools | Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) |
Functional Options
The functional options pattern lets a constructor stay backward compatible while accepting an open-ended set of optional settings. Callers pass only what differs from the defaults; new options never break old call sites.
Core Rules
- Reach for functional options at 3+ optional parameters or whenever the API will grow.
- The
options struct is unexported. Only the package owns its shape.
- The
Option interface has an unexported apply method. No external package can forge an option.
- Defaults go inside the constructor, before options are applied.
- Required parameters stay positional; only the optional ones go through
...Option.
- Prefer the interface form over closures — it composes better with testing, debugging, and
fmt.Stringer.
When to Use What
| Situation | Pattern |
|---|
| 0–2 optional params, stable API | Plain positional or named args |
| Config that callers usually pass whole | Config struct |
| 3+ optional params, growing API | Functional options |
| Mix of "must set together" + "rare overrides" | Config struct + small Option set |
Read references/options-vs-struct.md when choosing between options and a plain config struct, or designing a hybrid.
The Canonical Pattern
package db
import "go.uber.org/zap"
type options struct {
cache bool
logger *zap.Logger
}
type Option interface {
apply(*options)
}
type cacheOption bool
func (c cacheOption) apply(o *options) { o.cache = bool(c) }
func WithCache(enabled bool) Option { return cacheOption(enabled) }
type loggerOption struct{ log *zap.Logger }
func (l loggerOption) apply(o *options) { o.logger = l.log }
func WithLogger(log *zap.Logger) Option { return loggerOption{log: log} }
func Open(addr string, opts ...Option) (*Connection, error) {
o := options{
cache: true,
logger: zap.NewNop(),
}
for _, opt := range opts {
opt.apply(&o)
}
return &Connection{}, nil
}
Caller Experience
db.Open(addr)
db.Open(addr, db.WithLogger(log))
db.Open(addr, db.WithCache(false), db.WithLogger(log))
Compare to the alternative where all defaults must be repeated:
db.Open(addr, db.DefaultCache, zap.NewNop())
Why an Interface, Not a Closure?
type Option func(*options)
The interface form wins on:
- Testability — option values can be compared in tests.
- Debuggability — option types can implement
fmt.Stringer.
- Documentation —
godoc lists each option type explicitly.
- Extensibility — options can implement additional interfaces (e.g.,
Validate()).
Closures are shorter to write; they pay for that shortness in introspection.
Defaults
Set defaults before applying options. A constructor that ignores its defaults is a bug magnet:
o := options{
cache: true,
logger: zap.NewNop(),
}
for _, opt := range opts {
opt.apply(&o)
}
If a default needs computation (a temp dir, a process-wide ID), build it once during the constructor — not at package init.
Quick Reference
type options struct { ... }
type Option interface { apply(*options) }
type widgetOption Widget
func (w widgetOption) apply(o *options) { o.widget = Widget(w) }
func WithWidget(w Widget) Option { return widgetOption(w) }
func New(required string, opts ...Option) (*Thing, error) {
o := options{ }
for _, opt := range opts { opt.apply(&o) }
return build(required, o)
}
Anti-Patterns
| Anti-pattern | Why it hurts | Do this instead |
|---|
Exporting the options struct | External code mutates internals | Keep it unexported |
Option with an exported Apply | Anyone can build an option | Unexported apply method |
| Applying options before defaults | Defaults overwrite caller intent | Defaults first, then apply |
func Option(*options) closures | Opaque in tests/logs | Interface form |
| 7+ positional required params | Caller error-prone | Promote them into a config or options |
Mixing required and optional through ...Option | Required is no longer required | Keep required positional |
Verification Checklist
References