Functional options pattern in Go: Flexibility that won’t make future-you sigh loudly


SDK authors live in a permanent tug-of-war:
- Users want a simple constructor they can paste and ship.
- Maintainers want room to grow without breaking everybody’s build on the next release.
That second part matters a lot right now, because a lot of people are still relatively early in their software careers.
Approximately one in three developers has coded professionally for four years or less. That matters because unclear or fragile APIs disproportionately hurt newer developers—they don’t have scars yet.
If you’ve ever stared at a public API and thought, “How do I extend this without setting the ecosystem on fire,” congrats: you’re thinking like a library author.
Smarty has discovered that a combination of functional options and namespaces (searchable labels for those functional options) is one of the most effective solutions to this problem in Go.
Here’s what you can expect to learn in this blog:
- First, a quick clarification: Go absolutely lets you pass parameters
- Rob Pike’s functional options idea
- What “an option” means in practice
- Why variadic options “stand the test of time”
- “Exported” vs “unexported”: The uppercase rule that keeps APIs clean
- Leveraging options + namespaces
- How this maps to “wireup” and “client” in a Go SDK
- The “breaking change” question every library/SDK author has to answer
- A practical wrap-up
We’ll be the first to admit this looks a little ironic—our Go SDK doesn’t yet follow the namespace guidelines we’re about to share. It’s a lesson we learned the hard way, and we’re hoping to spare you from making the same mistake.
While namespaces require a bit of extra code and some careful design, the payoff is well worth it. Done right, they dramatically improve usability and searchability, making life easier for both your internal teams and anyone building with your Go SDK.
You can test out any of our products in our Live API playground for free, or continue reading. This blog is for anyone designing public APIs in Go—especially SDKs, libraries, or internal platforms with multiple users—and it aims to show newer developers (and even experienced ones who may not have learned) how to leverage more functional options while working in Go.
First, a quick clarification: Go absolutely lets you pass parameters
Go isn’t allergic to parameters. What Go doesn’t have is:
- Optional parameters/default arguments: In languages with optional parameters, someone might say something like “I’ll take a burger… and if you don’t hear otherwise, add fries.” Meaning the kitchen would just add fries by default if someone doesn’t mention them. But Go doesn’t allow for optional parameters. You must say exactly what you do want. “I’ll take a burger with fries,” or “I’ll take a burger without fries.” Go doesn’t read between the lines.
- Function overloading (same name, different signatures): Go does not support function or method overloading. You can't have multiple functions with the same name but different parameter lists within the same scope. Instead, Go encourages idiomatic alternatives like distinct function names, variadic functions, interfaces & generics, and the functional options pattern. This is because Go is optimized for readability, maintainability, large teams, and future-proofing projects.
Which means if you design a long parameter list constructor like:
NewClient(authID, authToken, baseURL, timeout, retries, enableTracing, ...)
The function signature becomes your public interface. And in Go, or any language really, changing the parameters in a public function signature is one of the most direct ways to cause breaking changes for every downstream user.
You’ve basically built a future bug farm. Every new feature either forces a breaking change, adds more positional arguments, or introduces subtle misconfiguration bugs that are hard to see at the call site. The compiler will keep you honest (picky, but fair), and your users will feel every breaking change immediately.
This is why Smarty relies on functional options. Functional options let you keep the constructor stable and make everything else additive.
Rather than inventing a workaround, Go’s core team embraced a pattern that fits the language’s constraints instead of fighting them.
Rob Pike’s functional options idea

Rob Pike laid out a clean approach way back in 2014: define an Option as a function that mutates some internal state, then accept a variadic list of options in your constructor. (For a more up-to-date version of this same concept, you can also check out Golang Cafe’s or Medium’s take on the matter of functional options.)
More simply put, instead of cramming every possible setting into the function itself, pass in little instructions that tweak things after the object is created, those little instructions being options.
That “variadic list” piece is the magic because (...Option) can be empty, or it can contain a dozen knobs, but your public signature stays the same.
What “an option” means in practice
In the simplest form, an option is “a function that manipulates a configuration struct.”
Let’s code:
type Option func(*config)Here’s an example of a simple option definition:
func WithAPICredentials(key string) Option {
return func(c *config) {
c.key = key
}
}This might look a bit strange–our function is returning another function, which will be invoked by the builder below to assign the user-supplied options to the config struct:
func BuildUSStreetAPIClient(options ...Option) *Client {
var config
for _, option := range append(defaultOptions, options) {
option(&config)
}
return Client{...}
}That’s the core pattern.
Why variadic options “stand the test of time”
First, a variadic function is one that receives an indefinite number of arguments /parameters.
When we’re talking about variadic functions, we simply mean that there’s an unknown, undefined number of parameters for that function.
Because anyone can keep adding new capabilities without changing the signature:
- v1 adds
Timeout(...) - v1.1 adds
MaxRetry(...) - v1.2 adds
DebugHTTPTracing() - v1.3 adds
WithHTTPClient(...)
Users who don’t care never have to update their code. Users who do care can opt in, one flag at a time if they wish, ignoring irrelevant updates that don’t affect their core functionalities.
In Smarty’s Go SDK, the wireup package exposes a stable set of client builders like BuildUSStreetAPIClient(options ...Option) and a growing menu of options (credentials, custom base URL, headers, retry behavior, HTTP debugging, etc.).
Configuration structs vs functional options
You’ll sometimes see Go libraries use a configuration struct instead of functional options:
cfg := Config{
Timeout: 5 * time.Second,
Retries: 3,
}
client := NewClient(cfg)This approach works well for static or data-heavy configurations.
But!
For public SDKs, it tends to leak internal structure into the API, making defaults, validation, and conditional behavior harder to centralize as the surface area grows.
Functional Options can do all that a struct would do, but structs can’t do all the things Functional Options patterns do.
For example, functional arguments (unlike structs) can return errors, so you can include validation inside the option itself.
Structs are good if you only have a small number of options, but Functional Options have better readability and documentation.
Functional options keep the public constructor stable while allowing the internal configuration to evolve without exposing every knob upfront.
Required vs. optional
A good mental model is that:
- Required inputs belong in the constructor signature.
- Optional behavior belongs in functional options.
If a value must always be provided for the client to function correctly, make it explicit. If it modifies behavior or enables a feature, make it an option.
For example, an API key or credential is typically required for most use cases at Smarty.
Retry policies, timeouts, debugging flags, and transport tweaks are optional.
Keeping that distinction clear makes APIs easier to read and harder to misuse or break.
“Exported” vs “unexported”: The uppercase rule that keeps APIs clean
Go doesn’t use public/private keywords. Instead:
- Uppercase identifier = exported (package users can see it)
- Lowercase identifier = unexported (package users can’t see it)
That matters because functional options usually mutate something you don’t want callers to touch directly (like config or a clientBuilder). You can keep the struct unexported and only expose the option functions.
Leveraging options + namespaces
One of the underrated wins of implementing this pattern in your code is searchability.
If your library exposes options in a consistent “namespace,” users can type “wireup.“ (period included) and instantly discover what’s available (in autocomplete, docs, and IDE symbol search).
One way to do this is by using a namespace type as a method receiver, with an Options variable of that type, which can effectively scope all options ‘behind’ that namespace.
The library/SDK author might write:
type namespace struct{}
var Options namespaceNow, we can add a receiver to our option definitions, promoting them to methods of the namespace type:
func (namespace) WithAPICredentials(key string) Option {
return func(c *config) {
c.key = key
}
}The caller now invokes the BuildUSStreetAPIClient, referencing any desired options from behind the newly defined Options namespace.
client := wireup.BuildUSStreetAPIClient(
wireup.Options.WithCredentials(apiKey),
wireup.Options.WithTimeout(5*time.Second),
wireup.Options.WithRetries(3),
wireup.Options.WithDebugHTTP(),
)As soon as a user types ”wireup.Options.”, their editor displays all available options (and nothing else).
And yes—this is the same reason teams suffix Slack channels with stuff like -help, -meetup, and -announcements. We want to make sense of the world around us, understand our options, and then implement the ones that make sense to us, disregarding those that aren’t useful.
Tim Peters said it best, and you can see it with this sneaky dev hack:
$ python3
>>> import this(The Zen of Python)
“Namespaces are one honking great idea — let’s do more of those!”
How this maps to “wireup” and “client” in a Go SDK
When someone uses a Go SDK, they usually want a client.
The wireup layer is where you assemble that client:
- credentials
- base URL (sometimes)
- HTTP transport settings
- retry/timeout policy
- debugging toggles
- headers/query params/feature flags
With functional options, you’re effectively saying:
“You, the user, decide which configuration functions I should invoke on your behalf during client construction.”
That makes the SDK ergonomic and maintainable because users only see relevant options, each option is clearly labeled, and users don’t have to memorize different parameters or their order.
Smarty’s own Go SDK docs describe this builder-based approach, pointing users at the wireup package for client creation.
The “breaking change” question every library/SDK author has to answer
Every public constructor parameter you add is a tax you collect forever.
Functional options reduce that tax by keeping your core constructor stable, letting new functionality ship as additive options. It won’t eliminate hard decisions, but it dramatically reduces how often you have to ask: “Is the value I’m adding worth the inconvenience to my users?”
Because most of the time, you can ship the value without the inconvenience.
A practical wrap-up
Functional options are one of Go’s most practical API design patterns.
They preserve a stable public constructor, keep internal configuration encapsulated, and make future enhancements additive instead of disruptive.
In a wireup package—where the entire job involves composing a client—options provide a clean way to let users opt into behavior while keeping the SDK readable, searchable, and backward compatible.
