Optionals

Question

What is the problem with optionals? In python we can just leave fields out and cherry pick ones we want?

Answer

Well lucky you. In go it’s not that simple, we’re left with trade offs when designing APIs that require optionals.

The purpose of this post is the break down the core options to approach this issue.

Self-referential functions

Interface

Go
// User interface looks like this. The user can optional functions that perform some
// modifcation to the interal state of the object, without having to expose the internal structure.
// This could be used to modify a lot of internal state and make complex changes through a simple interface.
pocketlog.New(pocketlog.LevelInfo, pocketlog.WithOutput(os.Stdout))

Implementation

Go
type Option func(*Logger)

// WithOutput returns a configuration function that sets the output of logs.
func WithOutput(output io.Writer) Option {
	return func(lgr *Logger) {
		lgr.output = output // closure: reaches up and grabs output? - idk
	}
}

func New(threshold Level, opts ...Option) *Logger {
	lgr := &Logger{threshold: threshold, output: os.Stdout}
	for _, configFunc := range opts {
		configFunc(lgr)
	}
	return lgr
}

Convetion says use With* naming functions mutate the struct

Rob pike’s blog

Question

When would I use it?

Answer

Typically when you have several optional constructor-time parameters and want New to stay stable as the list grows.

Pros

  • signature stays stable as options grow
  • minimal surface exposed to the caller
  • one option can set several internal fields coherently

Cons

  • more code: one exported func per knob
  • less discoverable than a struct (options scattered as funcs vs one named type)
  • as written (func(*Logger), no error) it can’t reject bad input; duplicate options silently last-win

Config struct

Interface

Go
// The caller fills one struct and hands it over. Anything left out falls back
// to a default. Every knob is visible in a single value.
pocketlog.New(pocketlog.LevelInfo, pocketlog.Config{
	Output:          os.Stdout,
	MaxMessageRunes: 500,
})

Implementation

Go
type Config struct {
	Output          io.Writer
	MaxMessageRunes int
}

func New(threshold Level, cfg Config) *Logger {
	lgr := &Logger{threshold: threshold, output: os.Stdout, maxMessageRunes: 1000}

	// zero/nil means "leave the default", we have to check each field by hand
	if cfg.Output != nil {
		lgr.output = cfg.Output
	}
	if cfg.MaxMessageRunes != 0 {
		lgr.maxMessageRunes = cfg.MaxMessageRunes
	}

	return lgr
}

one struct param, handle nil/zero for defaults

Pros

  • all knobs visible in one type
  • no per-option closures/allocs (just a struct copy)
  • reads clearly at the call site
  • trivial to pass around / unmarshal from a file or env

Cons

  • manual zero/nil check per field to mean “use default”
  • zero value is ambiguous: can’t tell “unset” from “explicitly zero”, so the caller can’t set a field to its own zero value (e.g. MaxMessageRunes: 0)

Usable zero value

Interface

Go
// No constructor at all. The caller builds the struct directly and the zero
// value already does something sensible - like http.Server{Addr: ...}.
lgr := pocketlog.Logger{Threshold: pocketlog.LevelInfo}
lgr.Infof("ready")

Implementation

Go
type Logger struct {
	Threshold       Level
	Output          io.Writer
	MaxMessageRunes int
}

// methods tolerate the zero value and fill defaults lazily
func (l *Logger) out() io.Writer {
	if l.Output == nil {
		return os.Stdout
	}
	return l.Output
}

exported fields, no constructor, make the user build it

Builder / method chaining

Interface

Go
// New returns a logger, each With* returns the same logger so calls chain.
lgr := pocketlog.New(pocketlog.LevelInfo).
	WithOutput(os.Stdout).
	WithMaxMessageRunes(500)

Implementation

Go
func (l *Logger) WithOutput(w io.Writer) *Logger {
	l.output = w
	return l // hand the same pointer back so the next call can chain
}

func (l *Logger) WithMaxMessageRunes(n int) *Logger {
	l.maxMessageRunes = n
	return l
}