All skills
cxuu avatar

/go-functional-options

@91f0c2e
by Charles Xucxuu/golang-skills165 stars
19

Use when designing a Go constructor or factory function with optional configuration — especially with 3+ optional parameters or extensible APIs. Also use when building a New* function that takes many settings, even if they don't mention "functional options" by name. Does not cover general function design (see go-functions).

Use this Skill: https://skilld.dev/gh/cxuu/golang-skills/go-functional-options

This session only. Nothing lands on disk.

referencesOPTIONS-VS-STRUCTS.md

≈1.7k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Functional Options vs Config Structs

Sources: source/google-go-styleguide/best-practices.md; source/uber-go-style/style.md Authority: project policy Minimum Go: any supported Go version Last verified: 2026-06-19

Both functional options and config structs solve the same problem — optional configuration for constructors — but they have different trade-offs. Choose based on API audience, extensibility needs, and complexity budget.

Project policy: use Google guidance to decide whether optional configuration is worth the complexity; when functional options are chosen, prefer Uber's interface-with-unexported-method implementation over closure-only options for debuggability and testability.

Contents

Decision Framework

Need optional configuration?
├─ Internal or test-only API?
│   └─ Config struct (simpler, less boilerplate)
├─ Public API with 3+ options?
│   └─ Functional options (extensible, backward-compatible)
├─ Options need validation or interdependencies?
│   └─ Functional options (validate in apply or constructor)
├─ All options usually specified together?
│   └─ Config struct (one literal, no With* ceremony)
└─ Options likely to grow over time?
    └─ Functional options (add With* without breaking callers)

Config Struct Pattern

A config struct groups optional parameters into a single struct passed to the constructor. Zero values serve as defaults, or provide a DefaultConfig().

Good

type Config struct {
    Timeout  time.Duration // zero = no timeout
    MaxRetry int           // zero = no retries
    Logger   *log.Logger   // nil = discard
}

func NewClient(addr string, cfg Config) *Client {
    if cfg.Logger == nil {
        cfg.Logger = log.New(io.Discard, "", 0)
    }
    return &Client{addr: addr, cfg: cfg}
}
c := NewClient("localhost:8080", Config{
    Timeout:  5 * time.Second,
    MaxRetry: 3,
})

Bad — Relying on unexported config fields in a public API:

type config struct {  // unexported: callers can't construct it
    timeout time.Duration
}

func NewClient(addr string, cfg config) *Client { ... }

When Zero Values Don't Work

If zero is a valid non-default value (e.g., timeout of 0 means "no timeout" but the desired default is 30s), use a pointer field or a sentinel value:

type Config struct {
    Timeout *time.Duration // nil = use default (30s), zero = no timeout
}

Comparison

Aspect Functional Options Config Struct
Boilerplate High (type + apply + With* per option) Low (one struct)
Extensibility Add With* — no breaking changes Add field — no breaking changes
Backward compat Excellent for public APIs Good (new fields get zero values)
Defaults Built into constructor Zero values or DefaultConfig()
Validation In apply or constructor loop In constructor after struct received
Discoverability With* functions appear in godoc All fields visible in one struct
Testability Compare options or test constructor output Compare struct literals
Caller experience Only specify what differs from defaults Must construct struct literal
Zero-value ambiguity None — unset options not applied May need pointer fields

When to Prefer Config Structs

  • Internal APIs — less ceremony, easier to read at call sites
  • Few options (1-3) — functional options overhead not justified
  • All options typically set together — no benefit to variadic style
  • No validation needed — simple field assignment suffices
  • Options are data, not behavior — struct fields map naturally
srv := NewServer(Config{
    Port:    8080,
    TLSCert: "/path/to/cert.pem",
    TLSKey:  "/path/to/key.pem",
})

When to Prefer Functional Options

  • Public/library APIs — callers shouldn't track internal config evolution
  • 3+ options that are individually optional
  • Complex defaults — default computation depends on other options
  • Validation per option — reject bad values at apply time
  • Options may grow — new With* functions are purely additive
srv := NewServer(
    WithPort(8080),
    WithTLS("/path/to/cert.pem", "/path/to/key.pem"),
    WithLogger(logger),
)

Caller Ergonomics

Functional options are most valuable when they keep call sites focused on the settings that differ from defaults.

Before — positional optional arguments hide meaning and force callers to remember default sentinel values:

conn, err := db.Open(addr, false, zap.NewNop(), 30*time.Second)

After — options name each non-default setting and allow the rest to stay inside the constructor:

conn, err := db.Open(
    addr,
    db.WithCache(false),
    db.WithLogger(logger),
)

Functional Options Implementation

Use an exported Option interface with an unexported apply method when options should be comparable, mockable, or able to implement extra interfaces:

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(opts *options) {
    opts.cache = bool(c)
}

func WithCache(c bool) Option {
    return cacheOption(c)
}

type loggerOption struct {
    Log *zap.Logger
}

func (l loggerOption) apply(opts *options) {
    opts.logger = l.Log
}

func WithLogger(log *zap.Logger) Option {
    return loggerOption{Log: log}
}

func Open(addr string, opts ...Option) (*Connection, error) {
    options := options{
        cache:  defaultCache,
        logger: zap.NewNop(),
    }

    for _, o := range opts {
        o.apply(&options)
    }

    return &Connection{}, nil
}

Avoid closure-only options by default:

type Option func(*options)

Closure options are concise, but they are harder to compare in tests, harder to describe in logs, and cannot implement additional interfaces.

Hybrid Approach

For APIs that need both convenience and extensibility, accept a config struct for common settings and functional options for advanced overrides:

func NewServer(cfg Config, opts ...Option) *Server {
    s := &Server{cfg: cfg}
    for _, o := range opts {
        o.apply(&s.cfg)
    }
    return s
}

Use this sparingly — it adds complexity. Prefer one approach per API.

Source: SKILL.md on GitHub

No alerts17d5 checks · Risk SAFE
  • Gen Agent Trust Hub17d

    This skill provides educational documentation and best practices for implementing the Functional Options pattern in Go, including comparisons with configuration structs and implementation examples.

  • Socket17d

    No alerts

  • Snyk17d

    Risk: LOW · No issues

  • Runlayer6mo

    1 file scanned · No issues

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 91f0c2e. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago

README badge

README badge for cxuu/golang-skills/go-functional-options