Why would you declare a Go Option as `func(*Client) error` rather than `func(*Client)`?
answer
- one exported type, so one decision for all
- bad values usually arrive as data, not literals
- stop at the first failure, return no client
- the message must name which option failed
- the other route validates the whole struct once
basics
~10 sSo an option can reject a bad value instead of storing it. The constructor then returns (*Client, error), stops at the first failing option and wraps the error, rather than panicking on caller input.
solid answer
~50 sThe error-returning form exists because some option arguments can be invalid — a negative dial timeout, a retry count of zero, an address that will not parse — and a library taking input from a caller should report that rather than panic. With `type Option func(*Client) error` the constructor becomes `New(addr string, opts ...Option) (*Client, error)`, calls each option in turn, and on the first non-nil error returns a nil client and a wrapped error naming the option that failed. The cost is real: every call site now handles an error even when it passed no options, and because `Option` is one exported type you cannot make half the options fallible. The usual middle ground is to keep options infallible and validate the assembled struct once after the loop, which also catches cross-field problems no single option can see.
code
go · 21 linestype Option func(*Client) error
func WithTimeout(d time.Duration) Option {
return func(c *Client) error {
if d <= 0 {
return fmt.Errorf("WithTimeout: must be positive, got %v", d)
}
c.timeout = d
return nil
}
}
func New(addr string, opts ...Option) (*Client, error) {
c := &Client{addr: addr, timeout: 5 * time.Second}
for _, opt := range opts {
if err := opt(c); err != nil {
return nil, fmt.Errorf("new client: %w", err)
}
}
return c, nil
}go deeper
Know the two possible shapes of the option type and that only the second lets an option reject a value: func(*Client) sets a field and cannot fail, func(*Client) error can return a reason for refusing.
Explain how the constructor's loop changes: it checks each option's error, stops at the first non-nil one, wraps it with %w for context, and returns a nil client so nothing half-built escapes.
Argue the trade for a package you own — every caller pays the error check, versus catching a negative duration at construction — and know that validating the assembled struct after the loop is the option that also catches cross-field problems.
This is a signature you cannot walk back once teams import it. Be ready to say what makes it worth imposing an error return on every call site, and how you would keep option error messages diagnosable across a package family.
## The choice An `Option` is a single exported function type, and every option in the package has that type. So the decision is made once for the whole package: ```go type Option func(*Client) // infallible type Option func(*Client) error // fallible ``` There is no middle case. You cannot make `WithTimeout` fallible and `WithRetries` not, unless you are willing to give up the single named type. ## Why the error form exists Some option arguments carry values that can be wrong in a way the type system does not catch. `WithTimeout(-1 * time.Second)` type-checks perfectly; a negative `time.Duration` is a legal value. So does `WithTimeout(0)`, which may mean "no timeout" or may be a caller forgetting to fill in a field. An option that opens something — a file of certificates, a socket for a custom dialer — can fail for reasons that have nothing to do with programmer error. The library's three possible responses are: store the bad value and misbehave later, panic, or return an error. Storing it is worst: the failure surfaces far away from its cause. Panicking is defensible only for a programmer error the caller could not possibly have taken from data, and option values very often come from a configuration file, so they usually are data. That leaves the error. ```go type Option func(*Client) error func WithTimeout(d time.Duration) Option { return func(c *Client) error { if d <= 0 { return fmt.Errorf("timeout must be positive, got %v", d) } c.timeout = d return nil } } func New(addr string, opts ...Option) (*Client, error) { c := &Client{addr: addr, timeout: 5 * time.Second} for _, opt := range opts { if err := opt(c); err != nil { return nil, fmt.Errorf("new client: %w", err) } } return c, nil } ``` Note the two disciplines in the loop: **stop at the first error**, and **return a nil client**. Returning a partially configured client alongside an error invites a caller to use it. Wrapping with `%w` keeps the underlying error inspectable by `errors.Is` and `errors.As` while adding the context that it came from client construction. ## What it costs The cost lands on every caller, including those who pass no options at all. `c := New(addr)` becomes `c, err := New(addr)` followed by a check, in code where nothing can fail. In a package where exactly one obscure option can fail, that is a poor trade for the other ninety-nine per cent of call sites. It also means the option's error message must be good enough to identify *which* option failed. `fmt.Errorf("timeout must be positive, got %v", d)` reads well; a bare `errors.New("invalid value")` from an anonymous closure gives the caller a message with no idea which of their six options produced it. Including the option name in the message is not optional here, because the constructor cannot add it — from inside the loop, one option looks like any other. ## The alternative: validate once, after the loop Most packages keep `Option` infallible and validate the finished struct: ```go func New(addr string, opts ...Option) (*Client, error) { c := &Client{addr: addr, timeout: 5 * time.Second} for _, opt := range opts { opt(c) } if err := c.validate(); err != nil { return nil, err } return c, nil } ``` This keeps the option type simple and gains something the per-option form cannot have: **cross-field validation**. A dial timeout of 2 seconds is fine, and a keep-alive interval of 30 seconds is fine, but a backoff window longer than the deadline it retries within is not, and no single option can see both values. Validating the assembled struct once catches the combination, and can report all the problems together instead of only the first. The constructor still returns an error in this design — the difference is where the error comes from, not whether there is one. ## When the error form is genuinely right - The package's options routinely take values parsed from strings — durations, URLs, sizes — where the parse itself can fail. - An option acquires a resource, so the failure is environmental rather than a bad literal, and the caller needs the underlying error. - The option list is built dynamically from configuration, so the caller cannot eyeball the values and wants each one checked as it is applied. ## What a weak answer looks like Saying "panic, because passing a negative timeout is a programmer error". It is a defensible position for a value that can only be a literal, but options are exactly the surface through which external configuration reaches a library, and a library that panics on bad configuration takes down the caller's process for something the caller could have reported and handled. The general rule holds: panic for invariants you control, return errors for input you do not.
- Why should the constructor return a nil client alongside the error rather than the partly configured one?Because a caller who ignores the error — and some will — then holds a client whose settings are half applied and whose behaviour matches nothing they wrote. Returning nil makes the mistake fail loudly at first use instead of quietly at the wrong timeout. It also keeps the contract simple to state: on a non-nil error, nothing usable was produced.
- What can validating the assembled struct catch that a per-option check cannot?Anything involving more than one field. An option sees only its own argument, so it cannot notice that the backoff window now exceeds the deadline it retries within, or that a keep-alive interval is longer than an idle timeout. Validation after the loop sees the whole struct, and can report every problem at once rather than stopping at the first.
- Is panicking ever the right response to a bad option value?Only when the value can be nothing but a literal written by the programmer and the mistake makes the object meaningless — and even then most library authors prefer an error, because options are the channel through which configuration files reach the package. A panic inside a library takes the caller's process down for a decision they were in a position to handle.
saying these in an interview costs you the question
- Storing an invalid option value and letting it fail later at the call site
- Panicking on caller-supplied configuration inside a library constructor
- Continuing through the remaining options after one has returned an error
- Returning a partially configured client together with a non-nil error
- An option error message that never names which option produced it
- Believing some options can return an error while others in the same type do not