sch39
Part 9 of 9 — Go Fundamentals for Developers

Functional Options Pattern: When It Helps and When It Doesn't

Oct 2026 · 4 min read

Functional options keep the constructor stable as optional configuration grows, while providing defaults and validation in one place. This pattern suits APIs with many independent options, but only adds indirection if configuration is minimal or all fields are required.

Full code: https://github.com/Sch39/go-series

Problem

Constructors with many parameters easily produce calls that are hard to read and fragile when new options are added. A config struct solves this, but makes the caller responsible for defaults and for distinguishing empty values from deliberately chosen values.

Outcome

Readers can:

  • Design constructors with consistent defaults and independent options.

  • Validate configuration when options are applied.

  • Determine when a plain constructor or config struct is simpler.

Implementation

Folder Structure

text
go-series/G01-09-functional-options/
├── go.mod
└── client/
    ├── client.go
    └── client_test.go

Setup

Initialize a single module (run from the go-series/ folder):

bash
mkdir -p G01-09-functional-options
cd G01-09-functional-options

go mod init github.com/sch39/go-series/g0109/functionaloptions

Code Blocks

Block 1: Variadic constructor — centralized defaults

go-series/G01-09-functional-options/client/client.go:

go
package client

import (
	"errors"
	"time"
)

type Client struct {
	Endpoint string
	Timeout  time.Duration
	Retries  int
}

type Option func(*Client) error

func New(options ...Option) (*Client, error) {
	client := &Client{
		Endpoint: "https://api.example.com",
		Timeout:  5 * time.Second,
		Retries:  2,
	}

	for _, option := range options {
		if err := option(client); err != nil {
			return nil, err
		}
	}
	return client, nil
}

Goal: Creating a constructor without options still produces a client with valid defaults.

Block 2: Option function — validated configuration

go-series/G01-09-functional-options/client/client.go:

go
func WithTimeout(timeout time.Duration) Option {
	return func(client *Client) error {
		if timeout <= 0 {
			return errors.New("timeout must be positive")
		}
		client.Timeout = timeout
		return nil
	}
}

func WithRetries(retries int) Option {
	return func(client *Client) error {
		if retries < 0 {
			return errors.New("retries must not be negative")
		}
		client.Retries = retries
		return nil
	}
}

Goal: Package the mutation and validation of each configuration item as an Option value.

Block 3: Closure — options capture caller arguments

go-series/G01-09-functional-options/client/client.go:

go
func WithEndpoint(endpoint string) Option {
	return func(client *Client) error {
		if endpoint == "" {
			return errors.New("endpoint must not be empty")
		}
		client.Endpoint = endpoint
		return nil
	}
}

Goal: Capture the endpoint value in a closure until the constructor applies that option.

Test Blocks

Test 1: Constructor without options uses defaults

go-series/G01-09-functional-options/client/client_test.go:

go
package client_test

import (
	"testing"
	"time"

	"github.com/sch39/go-series/g0109/functionaloptions/client"
)

func TestNewUsesDefaults(t *testing.T) {
	got, err := client.New()
	if err != nil {
		t.Fatalf("New returned error: %v", err)
	}
	t.Logf("endpoint=%s timeout=%s retries=%d", got.Endpoint, got.Timeout, got.Retries)

	if got.Endpoint != "https://api.example.com" || got.Timeout != 5*time.Second || got.Retries != 2 {
		t.Errorf("New defaults = %+v", got)
	}
}

Goal: Prove that variadic options may be empty and defaults are still applied.

Test 2: Options are applied in order

go-series/G01-09-functional-options/client/client_test.go:

go
func TestOptionsOverrideInOrder(t *testing.T) {
	got, err := client.New(
		client.WithEndpoint("https://internal.example.com"),
		client.WithTimeout(2*time.Second),
		client.WithRetries(4),
		client.WithRetries(1),
	)
	if err != nil {
		t.Fatalf("New returned error: %v", err)
	}
	t.Logf("endpoint=%s timeout=%s retries=%d", got.Endpoint, got.Timeout, got.Retries)

	if got.Endpoint != "https://internal.example.com" || got.Timeout != 2*time.Second || got.Retries != 1 {
		t.Errorf("configured client = %+v", got)
	}
}

Goal: Prove that options run in sequence so a later option can override a previous value.

Test 3: An invalid option fails construction

go-series/G01-09-functional-options/client/client_test.go:

go
func TestInvalidOptionStopsConstruction(t *testing.T) {
	got, err := client.New(client.WithTimeout(0))
	t.Logf("client=%v error=%v", got, err)

	if err == nil {
		t.Fatal("New returned nil error, want validation error")
	}
	if got != nil {
		t.Errorf("New returned client %v, want nil", got)
	}
}

Goal: Prove that a validation error stops the constructor without returning a partially configured object.

Run Test

Run from the go-series/G01-09-functional-options/ folder:

bash
go test -count=1 -v ./...

Output:

text
=== RUN   TestNewUsesDefaults
    client_test.go:15: endpoint=https://api.example.com timeout=5s retries=2
--- PASS: TestNewUsesDefaults (0.00s)
=== RUN   TestOptionsOverrideInOrder
    client_test.go:32: endpoint=https://internal.example.com timeout=2s retries=1
--- PASS: TestOptionsOverrideInOrder (0.00s)
=== RUN   TestInvalidOptionStopsConstruction
    client_test.go:41: client=<nil> error=timeout must be positive
--- PASS: TestInvalidOptionStopsConstruction (0.00s)
PASS
ok  	github.com/sch39/go-series/g0109/functionaloptions/client	6.740s

Why It Works

Variadic parameter: The options ...Option parameter is received as a []Option slice inside New. Callers may send zero or more options without changing the constructor signature when new options are added.

Function value and closure: Option is a named function type. Functions such as WithTimeout return a closure that stores the caller's argument, and that closure then receives a pointer to Client when the constructor is ready to apply it.

Defaults then overrides: The constructor builds valid state first, then applies options in order. Because each option mutates the same object, a later option can override a field already set by an earlier option.

Edge Cases

The same option repeated: Order becomes part of the API contract; in this implementation the value from the last option wins. Document that rule or reject duplicates if order must not affect the result.

Nil option: Callers can technically pass nil as an Option; calling it will panic. The API can reject nil options if nil may come from dynamic composition.

Partial mutation before an error: Earlier options may already have modified the object before a later option fails. Returning nil, err prevents the caller from using a partially configured object, but external side effects inside options should still be avoided.

Trade-offs

Strategy

Pro

Con

Functional options

Centralized defaults, extensible, expressive calls

More functions and indirection

Config struct

All configuration is visible as data

Zero values and defaults can be ambiguous

Plain constructor parameters

Simple for a few required parameters

Hard to read and change as options grow

Best practice: Use functional options when there are many optional configuration items with stable defaults. Choose plain parameters for a few required values, or a config struct when configuration needs to be stored, serialized, and validated as data.

Gotchas I Encountered

  • Two WithRetries calls applied in sequence and the output showed retries=1; the implementation does not automatically detect duplicate options.

  • WithTimeout(0) produced client=<nil> error=timeout must be positive, so the constructor does not expose an object that was mutated before the error.

Production Checklist

  • Is the configuration truly optional? Keep required parameters as constructor arguments.

  • Are defaults valid without options? Initialize everything before the options loop.

  • Can options fail? Return an error and do not expose a partial object.

  • Do option order or duplicates matter? Document it or validate explicitly.

  • Are options free of external side effects? Restrict options to changing the object's configuration.

References