Go Module 5 – Structs, pointers and methods
Constructors, useful zero values and options
Design Go types whose zero value works, write NewX constructors that check their input and return errors, and use functional options or a config struct.
What you will learn
- Design a type whose zero value is ready to use
- Write a NewX constructor that checks its input and returns an error
- Apply the functional options pattern with defaults and checks
- Choose between parameters, a config struct, options and a zero value
- Protect a type's rules with unexported fields, and spot zero values that hurt
Before you start
What you’ll learn · Before you start
What you will learn
- Design a type whose zero value is ready to use
- Write a NewX constructor that checks its input and returns an error
- Apply the functional options pattern with defaults and checks
- Choose between parameters, a config struct, options and a zero value
- Protect a type's rules with unexported fields, and spot zero values that hurt
Before you start
On this page
var buf bytes.Buffer followed by buf.WriteString("PNR ") works at once, with no constructor: the zero value of a
Buffer is an empty buffer ready to use. A retry policy whose zero value means 0 retries and no delay is different, and
needs a constructor that checks what it is given.
You will design types whose zero value works, write NewX constructors that return errors, and use functional options for settings. Every program below was run with Go 1.27.1, and the result it printed sits under it.
Useful zero values need no constructor
Go fills every variable with its zero value, so the cheapest type to use is one whose zero value is already a good start, as Effective Go says of Buffer and Mutex. This program uses five such types without setting any of them up:
// ready.go: standard-library types whose zero value is ready to use, and one of our own.
package main
import (
"bytes"
"fmt"
"net/http"
"strings"
"sync"
)
// Waitlist works from its zero value: append handles the nil slice.
type Waitlist struct {
mu sync.Mutex
names []string
}
func (w *Waitlist) Add(name string) int {
w.mu.Lock()
defer w.mu.Unlock()
w.names = append(w.names, name)
return len(w.names)
}
func main() {
var buf bytes.Buffer // an empty buffer, ready for writes
buf.WriteString("PNR ")
buf.WriteString("4521367890")
fmt.Println(buf.String(), buf.Len())
var sb strings.Builder
fmt.Fprintf(&sb, "%d seats", 72)
fmt.Println(sb.String())
var mu sync.Mutex // an unlocked mutex
mu.Lock()
mu.Unlock()
var client http.Client // nil Transport means http.DefaultTransport
fmt.Println(client.Transport == nil, client.Timeout)
var w Waitlist
w.Add("Asha")
fmt.Println(w.Add("Ravi"), w.names)
} Output
PNR 4521367890 14 72 seats true 0s 2 [Asha Ravi]
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run ready.go
| Type | Its zero value | In the output |
|---|---|---|
bytes.Buffer |
an empty buffer, ready for writes | PNR 4521367890 14 |
strings.Builder |
an empty builder | 72 seats |
sync.Mutex |
a mutex that nobody holds | locked and released, no output |
http.Client |
a client with the default transport and no timeout | true 0s |
Waitlist |
an empty list and a mutex nobody holds | 2 [Asha Ravi] |
Waitlist shows how a type of your own gets the same property: choose fields whose zero values already work. A nil
slice takes appends and nobody holds a zero Mutex, so var w Waitlist needed nothing before w.Add("Asha"). Useful
zero values also nest: a struct of such fields is itself ready to use. The http.Client row is the warning: its zero
value works, but “no timeout” is a default to choose on purpose.
Checkpoint
Check: Useful zero values
Constructors check what a zero value cannot
When a type has rules that its zero value breaks, a constructor: A function, by convention named New followed by the type's name, that builds a value ready to use, checks its input, and returns an error when no valid value can be made. builds the value instead. By convention it is a plain function named New plus the type, it returns the type, and it returns an error when the input cannot make a valid value:
// retry_ctor.go: a constructor that checks its arguments and returns an error.
package main
import (
"errors"
"fmt"
"time"
)
// RetryPolicy says how often and how long to retry. Code outside this package would get one
// only from NewRetryPolicy, because its fields are unexported.
type RetryPolicy struct {
retries int
baseDelay time.Duration
}
func NewRetryPolicy(retries int, baseDelay time.Duration) (RetryPolicy, error) {
if retries < 0 {
return RetryPolicy{}, fmt.Errorf("retries must be 0 or more, got %d", retries)
}
if baseDelay <= 0 {
return RetryPolicy{}, errors.New("baseDelay must be positive")
}
return RetryPolicy{retries: retries, baseDelay: baseDelay}, nil
}
// Delays lists the wait before each retry: the base delay, doubled each time.
func (p RetryPolicy) Delays() []time.Duration {
var out []time.Duration
d := p.baseDelay
for range p.retries {
out = append(out, d)
d *= 2
}
return out
}
func main() {
p, err := NewRetryPolicy(3, 200*time.Millisecond)
fmt.Println(p.Delays(), err)
_, err = NewRetryPolicy(-1, time.Second)
fmt.Println(err)
var zero RetryPolicy // a value the constructor never returned
fmt.Println(zero.Delays(), len(zero.Delays()))
} Output
[200ms 400ms 800ms] <nil> retries must be 0 or more, got -1 [] 0
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run retry_ctor.go
NewRetryPolicy(3, 200ms) returned a policy whose delays double, 200, 400 and 800 ms, and NewRetryPolicy(-1, …)
returned the error that names the bad value. The fields are unexported, so in a package of its own the type keeps an
invariant: A rule about a value's fields that its methods rely on and keep true, such as a retry count never being negative. Unexported fields and a constructor keep other packages from breaking it.: every RetryPolicy built by the constructor has 0 or more retries and a positive
delay. The last line is the gap: var zero RetryPolicy never went through the constructor, and it means no retries.
Go constructors are ordinary functions, so a type can have several.
Key idea
Give a type a useful zero value where its fields allow one; where they do not, a NewX constructor checks the input and returns an error instead of a broken value.
Checkpoint
Check: Constructors that check and return errors
Functional options name only the settings a caller changes
A policy with many settings, most of them usually left at their defaults, needs neither a long parameter list nor a value per setting. With functional option: An argument of a function type, such as func(*Policy) error, that a constructor applies to its settings in order, so that a call names only the settings it changes. the constructor starts from the defaults and applies only the options it is given, here in a package, retry, that the program imports:
cmd/options/main.go · retrymod/cmd/options/main.go
// The options program: New with the defaults, with two options, and with an invalid one.
package main
import (
"fmt"
"time"
"example.com/retrymod/retry"
)
func main() {
p, err := retry.New()
fmt.Println(p, p.Delays(), err)
p, err = retry.New(retry.WithRetries(4), retry.WithBaseDelay(50*time.Millisecond))
fmt.Println(p, p.Delays(), err)
_, err = retry.New(retry.WithRetries(-1))
fmt.Println(err)
} Output
retries 2, first after 100ms [100ms 200ms] <nil> retries 4, first after 50ms [50ms 100ms 200ms 400ms] <nil> WithRetries(-1): retries cannot be negative
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run main.go
retry/retry.go · retrymod/retry/retry.go
// Package retry builds retry policies whose settings have been checked.
package retry
import (
"fmt"
"time"
)
// Policy says how often to retry and how long to wait first. Its fields are unexported, so code
// outside this package can set them only through New and its options.
type Policy struct {
retries int // retries after the first attempt; 0 means never retry
baseDelay time.Duration // the wait before the first retry; 0 means the default
}
// The defaults, for every setting that no option gives, and the most a policy may wait in all.
const (
defaultRetries = 2
defaultDelay = 100 * time.Millisecond
maxTotalWait = 10 * time.Second
)
// Option changes one setting of a Policy, or says why it cannot.
type Option func(*Policy) error
// WithRetries sets the number of retries after the first attempt.
func WithRetries(n int) Option {
return func(p *Policy) error {
if n < 0 {
return fmt.Errorf("WithRetries(%d): retries cannot be negative", n)
}
p.retries = n
return nil
}
}
// WithBaseDelay sets the wait before the first retry; each later wait doubles.
func WithBaseDelay(d time.Duration) Option {
return func(p *Policy) error {
if d <= 0 {
return fmt.Errorf("WithBaseDelay(%v): the delay must be positive", d)
}
p.baseDelay = d
return nil
}
}
// New starts from the defaults and applies the options in order; the first invalid one stops it.
// Then it checks the rule that no single option can: the waits must add up to at most maxTotalWait.
func New(opts ...Option) (Policy, error) {
p := Policy{retries: defaultRetries, baseDelay: defaultDelay}
for _, opt := range opts {
if err := opt(&p); err != nil {
return Policy{}, err
}
}
if total := p.totalWait(); total > maxTotalWait {
return Policy{}, fmt.Errorf("%d retries from %v wait %v or more, over the %v budget", p.retries, p.delay(), total, maxTotalWait)
}
return p, nil
}
// totalWait adds up the waits, and stops as soon as the sum passes maxTotalWait.
func (p Policy) totalWait() time.Duration {
var total time.Duration
d := p.delay()
for range p.retries {
total += d
if total > maxTotalWait {
break
}
d *= 2
}
return total
}
func (p Policy) delay() time.Duration {
if p.baseDelay == 0 {
return defaultDelay
}
return p.baseDelay
}
// Delays lists the wait before each retry.
func (p Policy) Delays() []time.Duration {
var out []time.Duration
d := p.delay()
for range p.retries {
out = append(out, d)
d *= 2
}
return out
}
func (p Policy) String() string {
return fmt.Sprintf("retries %d, first after %v", p.retries, p.delay())
} Step through the second call:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The defaults. New starts from a Policy holding the defaults, 2 retries with 100 ms before the first, so a caller who passes no options gets exactly that.
- The first option. WithRetries(4) returned a function. New calls it with a pointer to the policy; it checks that 4 is not negative and sets retries to 4.
- The second option. WithBaseDelay(50ms) checks that the delay is positive and sets it. Options run in the order the caller wrote them.
- The whole policy. Each option checked only its own value. New then checks the rule that spans both, the sum of the waits, 50 + 100 + 200 + 400 ms, against its limit of 10 s.
- The result. Nothing failed, so New returns the policy with a nil error, and its delays are 50, 100, 200 and 400 ms.
- An invalid option. Called with WithRetries(-1) instead, the option returns an error. New stops at once and returns a zero Policy with that error, so no half-set policy escapes.
An option is a function, func(*Policy) error, that changes one setting or says why it cannot. retry.New() gave the
defaults, 2 retries after 100 ms; two options gave 4 retries starting at 50 ms; and WithRetries(-1) stopped New with
its error. After the options, New checks the policy as a whole, so that its waits add up to 10 s at most. A Config
struct is the other common answer for many settings: callers fill in fields, and the zero ones mean the defaults.
Options are the better fit for a library with settings that callers rarely change.
Checkpoint
Check: Functional options
Options in depth: order, errors and the zero a Config cannot say Pro: opens with a Pro, Premium or Ultimate pass
Options look simple, but their order, their errors and their values all carry rules, and a Config struct breaks on a case that options handle. This part runs each of them and weighs the four ways to build a value against the case of your own type.
In this part:
- Options run in order, so the last one wins
- A rule that spans settings is checked once, at the end
- A Config struct cannot tell “not set” from 0
- Choosing how callers build your type
3 runnable examples (Go, cmd/config/main.go, retry/config.go) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Zero values that hurt, and the rules a package protects Pro: opens with a Pro, Premium or Ultimate pass
A zero value is never refused by the compiler, so a type’s defaults decide what a forgotten setting does in production. This part reproduces three such failures, a client that waits for ever, a default that one caller changes for everyone and a copy that splits a value in two, and tests what unexported fields protect.
In this part:
- A zero http.Client waits as long as the server does
- A package-level default that one caller changes for all
- A value that is ready at zero may still refuse to be copied
- Unexported fields stop invalid literals, not zero values
5 runnable examples (Go) · about 5 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
The practice set covers zero values, constructors and options, weakest idea first, and every choice carries its own explanation:
Practice that adapts to you
Practise this lesson
9 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): Name two standard-library types whose zero value is ready to use, and say what that means.
bytes.Buffer and sync.Mutex. A Buffer declared with var buf bytes.Buffer already accepts writes, as an empty
buffer, and a zero Mutex is not held by anyone, so the first Lock succeeds. Neither needs a constructor or an
initialisation call, and a struct whose fields are all like them is ready to use as soon as it is declared.
Three longer questions of the module bank build on this lesson:
-
Mid to senior
You are designing NewClient for a library that 40 services use. It has one required setting, the base URL, and nine optional ones with defaults; one of them, retries, must accept 0. Do you use functional options, a Config struct or something else? Defend the design.
-
Mid to senior
During a payment provider's slowdown, a service's goroutine count climbs to 80,000 and it runs out of memory, although its handlers normally finish in milliseconds. The outgoing calls use http.Get. What happened, and how do you change the code and the team's defaults?
-
Mid to senior
Two teams share a package whose constructor reads an exported variable, DefaultRetries. After one team sets it to 10 in an init function, the other team's service starts sending a payment request up to ten times. Explain what happened and redesign the package.
Key takeaways
- Types with useful zero values need no constructor: choose fields such as nil slices and zero mutexes that are ready to use, and the property carries over to structs that hold them.
- Constructors are plain NewX functions that check their input and return an error, and unexported fields make them the only way other packages can set those fields.
- Functional options start from defaults and apply only the settings a caller names, in order, stopping at the first invalid one; a Config struct is the other common answer for many settings.
Terms in this lesson
- constructor
- A function, by convention named New followed by the type's name, that builds a value ready to use, checks its input, and returns an error when no valid value can be made.
- functional option
- An argument of a function type, such as func(*Policy) error, that a constructor applies to its settings in order, so that a call names only the settings it changes.
- invariant
- A rule about a value's fields that its methods rely on and keep true, such as a retry count never being negative. Unexported fields and a constructor keep other packages from breaking it.
References
- Effective Go: Allocation with new (The Go Authors)
- Effective Go: Constructors and composite literals (The Go Authors)
- Package bytes: Buffer (The Go Authors)
- Package strings: Builder (The Go Authors)
- Package sync: Mutex (The Go Authors)
- Package net/http: Client (The Go Authors)
- Package net/http: DefaultClient (The Go Authors)
- Self-referential functions and the design of options (Rob Pike, command center)
- Go Code Review Comments: Interfaces (The Go Authors)
- The Go Programming Language Specification: Exported identifiers (The Go Authors)
Report a problem with this lesson
Kept only in this browser. Your Learn progress