Several Ways to Handle Optional Parameters in Go
Go deliberately does not support optional or default function parameters like some other languages. Function signatures are explicit, which keeps call sites and APIs predictable.
When an API genuinely needs optional configuration, Go developers usually choose one of a few established patterns.
1. Variadic Parameters
A variadic parameter (...) is useful when the optional part is simply zero or more values of the same type:
func greet(message string, names ...string) {
for _, name := range names {
fmt.Printf("%s, %s!\n", message, name)
}
}
func main() {
greet("Hello")
greet("Hello", "Alice", "Bob", "Charlie")
}message is required, while names may contain zero, one, or many values.
Use variadic parameters when the semantics really are “a list of additional values.” They are less suitable when each optional argument has a different meaning.
2. A Configuration Struct
For several independent options, a struct makes names and types explicit:
type GreetOptions struct {
Message string
Names []string
}
func greet(options GreetOptions) {
for _, name := range options.Names {
fmt.Printf("%s, %s!\n", options.Message, name)
}
}
func main() {
greet(GreetOptions{
Message: "Hello",
Names: []string{"Alice", "Bob"},
})
greet(GreetOptions{
Message: "Hi",
Names: []string{"Charlie"},
})
}A config struct is easy to document, validate, serialize, and extend. The main design question is how zero values should behave. If the zero value is not useful, provide a constructor or validation function.
3. Functional Options
The functional-options pattern works well for constructors with many optional settings, especially in libraries:
type GreetOptions struct {
Message string
Names []string
}
type Option func(*GreetOptions)
func WithMessage(message string) Option {
return func(o *GreetOptions) {
o.Message = message
}
}
func WithNames(names ...string) Option {
return func(o *GreetOptions) {
o.Names = names
}
}
func greet(options ...Option) {
opts := GreetOptions{
Message: "Hello",
Names: []string{"World"},
}
for _, option := range options {
option(&opts)
}
for _, name := range opts.Names {
fmt.Printf("%s, %s!\n", opts.Message, name)
}
}
func main() {
greet()
greet(WithMessage("Hi"), WithNames("Alice", "Bob"))
}The function starts with defaults and applies each option in order. This pattern keeps call sites readable as configuration grows, but it adds abstraction that may be unnecessary for small internal functions.
4. Fluent or Builder-Style Configuration
A mutable builder can provide chainable methods:
type GreetConfig struct {
Message string
Names []string
}
func NewGreetConfig() *GreetConfig {
return &GreetConfig{
Message: "Hello",
Names: []string{"World"},
}
}
func (gc *GreetConfig) SetMessage(message string) *GreetConfig {
gc.Message = message
return gc
}
func (gc *GreetConfig) SetNames(names ...string) *GreetConfig {
gc.Names = names
return gc
}
func (gc *GreetConfig) Greet() {
for _, name := range gc.Names {
fmt.Printf("%s, %s!\n", gc.Message, name)
}
}
func main() {
NewGreetConfig().
SetMessage("Hi").
SetNames("Alice", "Bob").
Greet()
}This can read naturally for complex builders, but it introduces mutable state and is less idiomatic than a plain struct for many Go APIs.
Which Pattern Should You Choose?
- Variadic parameter → zero or more values of one type.
- Configuration struct → several named settings with clear fields.
- Functional options → extensible constructor configuration, especially for public packages.
- Builder style → step-by-step configuration where chaining genuinely improves readability.
Conclusion
Go does not need language-level optional parameters to support flexible APIs. The key is to choose a pattern whose trade-offs match the problem. Prefer the simplest explicit design first, and reach for functional options or builders only when the number and evolution of configuration settings justify the extra machinery.