Post

Structs are configuration

The “functional options” pattern in Go is very silly and you should not use it.

If you haven’t seen it before, it typically looks like this, with each WithX function modifying some private field of the server.Server type returned by server.New().

1
2
3
4
5
6
svr := server.New(
    server.WithHost("localhost"),
    server.WithPort(8080),
    server.WithTimeout(time.Minute),
    server.WithMaxConn(120),
)

Or, if you’re AWS, for some reason, it looks like this.

1
2
3
client := s3.NewFromConfig(aws.Config{}, func(o *s3.Options) {
    o.Region = "us-east-1"
})

Which is even stranger since the configuration type being changed is public, but there are many dubious design decisions in the AWS SDK for Go.

Developers typically discover functional options the following way:

  1. They have defined a type;
  2. That type has an associated New() method;
  3. There is a variable amount of configuration associated with that type;
  4. That configuration needs to be passed into New();
  5. Go doesn’t have keyword arguments;
  6. Google/Bing/DDG the problem;
  7. Functional options.

Step 4 is where things have gone wrong, because the type already has associated configuration. It came free with your type definition.

Don’t pass additional options into a constructor. Instead, make the user-configurable fields of your type public.

You may have to do a little more work to make this happen, but it will make your API cleaner and your users happier.

Handling defaults

One of the reasons developers may want to hide the true value of a configurable field is because they want to set default values in the type’s New() method.

But one of the important principles of Go is to make the zero value useful. Plenty of types in Go, such as sync.Mutex, bytes.Buffer, strings.Builder, and the like, are immediately usable without explicit initialization. Perhaps your type can be one of them: it would simplify your API and allow your type to be included in other types, like how sync.Mutex is used across the stdlib, by simply defining a field.

Consider whether it’s possible to accept the zero value of each field as a reasonable default.

Using the same server example as earlier, it might be reasonable to:

  1. Interpret an empty Host string as localhost. Parts of the Go standard library already do this, so you may not have to do extra work here.
  2. Interpret a 0 Port as a random available port. This is actually how port 0 is interpreted by bind(2), so you may not have to do extra work here.
  3. Interpret a 0 Timeout as indefinite. This is how Go does it elsewhere. Sometimes it is better not to provide a default, because clients and servers may have different tolerances depending on the work they are doing, and it can lead to apparently-inconsistent behavior if the user is unaware of it.
  4. Interpret a 0 MaxConn as equal to no limit, for the same reason as Timeout above.

In cases where the zero value can never be valid, you can write your own method to interpret it as a default on-the-fly, as it’s needed. cmp.Or makes this especially easy to do.

Handling initialization

If you have some work to do when your type is first constructed, first, it’s worth asking whether a constructor is really the right place to do that work. A new http.Server does not immediately begin serving requests, nor does an exec.Cmd immediately run a system command. The declaration of the type is separate from the type performing some behavior.

Don’t underestimate how useful this separation of concerns can be, because once you have a constructor that performs an action, it is very difficult to decouple later. Being able to treat an exec.Cmd as just data, for instance, is very useful to me as a user of the os/exec package. Not only does it afford me a lot of flexibility on how the command is run - CombinedOutput() works differently from Run() which works differently from Start() - it also lets me pass the command around before running it. Being able to treat it as data is especially useful for tests, where I might not want the command to execute at all, but I do want to inspect and compare its configuration against what is expected.

If you absolutely must perform some action at initialization time, you still may not need a constructor to do it. For example, you can use a private sync.Once field to execute a private init() function exactly one time, in a goroutine-safe way.

If your initialization process can return an error, you can use sync.OnceValue(s) to consistently return the same error value whenever type methods are called.

Here is an example of an io.WriteCloser that turns writes into channel sends and has no constructor nor an explicit initialization function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package main

import (
    "fmt"
    "sync"
    "time"
)

type chanWriter struct {
    c    chan byte
    once sync.Once
}

func (w *chanWriter) init() {
    w.c = make(chan byte)
}

func (w *chanWriter) Channel() chan byte {
    w.once.Do(w.init)
    return w.c
}

func (w *chanWriter) Write(p []byte) (int, error) {
    w.once.Do(w.init)
    for _, b := range p {
        w.c <- b
    }
    return len(p), nil
}

func (w *chanWriter) Close() error {
    w.once.Do(w.init)
    close(w.c)
    return nil
}

var w chanWriter

func main() {
    go func() {
        fmt.Fprintln(&w, "Hello, world!")
        w.Close()
    }()
    for b := range w.Channel() {
        fmt.Print(string(b))
        time.Sleep(100 * time.Millisecond) // For illustrative purposes only.
    }
}

Playground

By spec, it’s not defined whether Write() or Channel() will be called first in this program. They may even be called simultaneously. But the sync.Once employed here lets the initialization be done safely and without expanding the public API of the chanWriter type.