Skip to content

Instantly share code, notes, and snippets.

@creachadair
Last active July 28, 2023 23:12
Show Gist options
  • Save creachadair/4b8370034aedf7f6a8449ed5254a45c1 to your computer and use it in GitHub Desktop.
Save creachadair/4b8370034aedf7f6a8449ed5254a45c1 to your computer and use it in GitHub Desktop.
My current preferred option plumbing style in Go

Option Plumbing in Go

This gist outlines my current preferred library package organization style in Go. There are a few explanatory comments, but for the most part it's just an extended example.

// Package thing is named for the concept it implements.
package thing

// Thing is the principal type that implements the concept.
// Although this does stutter a little with the package name,
// I find that leads to less stutter elsewhere in the package.
type Thing struct {
   name string  // unexported implementation details
   val  int
}

// New constructs a new Thing. Required parameters are positional,
// options are carried on a pointer. The pointer to Options is ready
// for use and provides default values.
func New(name string, opts *Options) (*Thing, error) {
  if name == "" {
    return nil, errors.New("invalid name")
  }
  return &Thing{
    name: name, 
    val:  opts.value(), // use methods to read the options
  }, nil
}

// Options are optional settings for a Thing.
// A nil is ready for use, and provides default values as described.
type Options struct {
  // The initial value of the thing. If zero or negative,
  // it defaults to 1.
  Value int 
}

// Each option gets an unexported method that checks for nil and
// other constraints, and returns a suitable value. This allows
// the caller to treat a nil *Options like a value, but you can
// still have non-trivial default values besides just zero.
func (o *Options) value() int {
  if o == nil || o.Value <= 0 {
    return 1
  }
  return o.Value
}

This style works well at the usage site:

package main

import (
  "log"
  
  "github.com/creachadair/thing"
)

func main() {
  // Even though t is a thing.Thing, we don't see that stutter
  // in most cases. The only time we have to write out the type
  // explicitly is if we're doing some shenanigans.
  t, err := thing.New("foobar", nil)
  if err != nil {
    log.Fatal(err)
  }
  doStuffWith(t)
  
  // Moreover, if we have to add options later, it's easy to update
  // the call sites just by replacing the second argument:
  u, err := thing.New("bazquux", &thing.Options{
    Value: 45,
  })
  if err != nil {
    log.Fatal(err)
  }
  doStuffWith(u)
}

Discussion

I have tried a lot of different schemes for organizing packages, and I feel that this one is the best tradeoff between compactness and flexibility. "Functional" options have also been popular in the Go community, but I generally find them to be more trouble than they're worth, for the following reasons:

  • The documentation tends to be scattered, as each option is defined separately. This also makes it hard to document the interaction between different options, and hard to reason about default values (especially when they change).
  • Variadic function calls are harder to find and refactor at the usage site.
  • Simple-valued settings require more plumbing with functional options.
  • I prefer the visual layout of a struct literal to the concatenation of function calls. It also gives you an easy place to document what is going on.

For example:

// With functional options:
t, err := thing.New("name", thing.WithValue(25), thing.WithCorkedBat(true), thing.FatFraction(0.25))

// With struct options:
t, err := thing.New("name", &thing.Options{
  Value:       25,
  CorkedBat:   true,  // for legacy reasons; remove once #1234 is fixed
  FatFraction: 0.25,
})

The functional version can be made a little more legible by splitting up the line, but you still have to repeat the package name all over the place:

t, err := thing.New("name",
   thing.WithValue(25),
   thing.WithCorkedBat(true),  // for legacy reasons; set false once #1234 is fixed
   thing.FatFraction(0.25),
)

It's fine, but I find the incomplete lines jarring.

You can also pass in options by value, but then you have to explicitly construct an empty options (or define a default somewhere). I like the visual compactness of nil as a "use the defaults" signal. Plus, even if you pass by value, you'll probably want methods to handle "interesting" (non-zero) defaults.

package thing

var DefaultOptions = Options{Value: 25}  // this is fine too

package main 

t, err := thing.New("name", thing.DefaultOptions)  // sure, that's fine
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment