Functional Options with Generics in Go: Clean, Type‑Safe Configuration
The Problem with Traditional Options
Go developers have long relied on the functional options pattern to keep constructors tidy while allowing callers to tweak only the fields they care about. The classic implementation uses a variadic slice of func(*Config) closures. It works, but it has two nagging drawbacks:
- Every option must accept the exact same concrete config type, so you can’t reuse an option across different structs without duplication.
- The compiler can’t verify that an option actually touches a field that exists — typos only surface at runtime.
In a codebase that grows beyond a handful of services, those issues turn into real maintenance friction.
Enter Generics
Since Go 1.18 we can parameterise the option function itself. An option becomes Option[T any] func(*T), and a constructor can accept ...Option[T]. The type parameter T is inferred from the concrete config struct you pass in, giving you compile‑time safety *and* reuse.
// Option is a generic functional option.
type Option[T any] func(*T)
// Apply runs all options against the target.
func Apply[T any](target *T, opts ...Option[T]) {
for _, o := range opts {
o(target)
}
}
Now any struct can be configured with the same helper, and the compiler guarantees each closure receives a pointer to the exact type you intend.
A Real‑World Example: Configuring an HTTP Client
Imagine a micro‑service that talks to three downstream APIs. Each client needs a base URL, a timeout, and optional middleware (logging, retries, auth). Instead of writing three near‑identical NewClient functions, we define a single ClientConfig and a handful of reusable options.
type ClientConfig struct {
BaseURL string
Timeout time.Duration
Transport http.RoundTripper
AuthToken string
}
// WithBaseURL sets the base URL.
func WithBaseURL(url string) Option[ClientConfig] {
return func(c *ClientConfig) { c.BaseURL = url }
}
// WithTimeout sets the request timeout.
func WithTimeout(d time.Duration) Option[ClientConfig] {
return func(c *ClientConfig) { c.Timeout = d }
}
// WithTransport injects a custom RoundTripper (e.g., logging, retries).
func WithTransport(rt http.RoundTripper) Option[ClientConfig] {
return func(c *ClientConfig) { c.Transport = rt }
}
// WithAuth adds a bearer token header via a custom transport.
func WithAuth(token string) Option[ClientConfig] {
return func(c *ClientConfig) {
c.AuthToken = token
// Wrap existing transport or default.
base := c.Transport
if base == nil {
base = http.DefaultTransport
}
c.Transport = &authTransport{base: base, token: token}
}
}
// authTransport adds Authorization header.
type authTransport struct {
base http.RoundTripper
token string
}
func (a *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req2 := req.Clone(req.Context())
req2.Header.Set("Authorization", "Bearer " + a.token)
return a.base.RoundTrip(req2)
}
// NewClient builds an *http.Client from the supplied options.
func NewClient(opts ...Option[ClientConfig]) *http.Client {
cfg := ClientConfig{
Timeout: 10 * time.Second,
}
Apply(&cfg, opts...)
return &http.Client{
Timeout: cfg.Timeout,
Transport: cfg.Transport,
}
}
Creating a client for the payments service now reads like a sentence:
paymentsClient := NewClient(
WithBaseURL("https://payments.example.com\)),
WithTimeout(5*time.Second),
WithAuth(os.Getenv("PAYMENTS_TOKEN\)),
)
If you later need a client for the notifications service that shares the same timeout and logging transport but uses a different auth scheme, you just compose the existing options — no copy‑paste required.
Why This Matters
Generics turn the functional options pattern from a convention into a type‑checked contract. You get IDE autocompletion, refactor‑safe renames, and the confidence that an option can’t silently apply to the wrong struct.
Beyond safety, the generic Apply helper eliminates boilerplate. Every new config type gets a one‑liner constructor; the only code you write are the domain‑specific option functions. This scales nicely when you have dozens of services, each with its own tweaks.
Putting It All Together
Adopting generic functional options in a team is straightforward:
- Place the
OptionandApplydefinitions in a sharedpkg/optionspackage. - Define a
Configstruct per component (HTTP client, gRPC server, DB pool, etc.). - Write option functions that return
Option[Config]. Keep them pure — just mutate the struct. - Export a single
New…function that callsApplyand builds the final object.
When a new requirement appears — say, adding a circuit‑breaker middleware — you add one option function and every consumer instantly gains the ability to enable it without touching their initialization code.
In my own projects this pattern has cut the lines of constructor code by roughly 40 % and eliminated a whole class of runtime configuration bugs. The next time you reach for a variadic func(*Config) slice, give the generic version a spin; the compiler will thank you.