Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with Go 1.27.1
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: Useful zero values need no constructor · Constructors check what a zero value cannot · Functional options name only the settings a caller changes. With a pass: 2 in-depth parts and 3 interview answers, about 9 minutes.

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: five types used straight from their zero values Go · ready.go
// 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

  1. Question 1 of 2 What does var buf bytes.Buffer give you, before any call?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An empty buffer, ready for writes

    ready.go wrote PNR 4521367890 into a zero Buffer and printed its length, 14.

  2. Question 2 of 2 A Registry type holds names in a field. Which field keeps var r Registry ready for r.Add("Asha")?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: names []string, because append works on a nil slice

    Choose fields whose zero values already work: nil slices, zero mutexes, zero Buffers.

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: NewRetryPolicy refuses what it cannot use Go · retry_ctor.go
// 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

  1. Question 1 of 2 With NewRetryPolicy from retry_ctor.go, what does this print?

    Read the code, then choose one answer.

    p, err := NewRetryPolicy(2, 0)
    fmt.Println(len(p.Delays()), err)
    Show the answer: Question 1 of 2

    Answer: 0 baseDelay must be positive

    A constructor returns a usable value or an error, never a half-checked value.

  2. Question 2 of 2 RetryPolicy has unexported fields and a constructor. Which value can other packages still create without it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The zero value, with var p RetryPolicy

    Design the zero value too, because callers can always make it.

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:

retry.New with no options, two options and a wrong one

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:

Interactive · stage by stage How New applies its options Step through retry.New(retry.WithRetries(4), retry.WithBaseDelay(50*time.Millisecond)), then the call that fails.
How New applies its options, step 6 of 6. Showing start from the defaults, WithRetries(4) (-1 refused), WithBaseDelay(50ms) (first delay is now 50ms), check the total wait (750ms: within the limit), return p, nil (retries 4, first after 50ms), return the error (retries cannot be negative). In focus: WithRetries(4), bad1, return the error.invalidinvalidtoo longstart from the defaultsretries 2, first 100msWithRetries(4)-1 refusedWithBaseDelay(50ms)first delay is now 50mscheck the total wait750ms: within the limitreturn p, nilretries 4, first after 50msreturn the errorretries cannot be negative

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. The second option. WithBaseDelay(50ms) checks that the delay is positive and sets it. Options run in the order the caller wrote them.
  4. 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.
  5. The result. Nothing failed, so New returns the policy with a nil error, and its delays are 50, 100, 200 and 400 ms.
  6. 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

  1. Question 1 of 2 With the retry package of the lesson, what does this print?

    Read the code, then choose one answer.

    p, err := retry.New(retry.WithRetries(3))
    fmt.Println(p.Delays(), err)
    Show the answer: Question 1 of 2

    Answer: [100ms 200ms 400ms] <nil>

    New starts from the defaults and applies only the options it is given.

  2. Question 2 of 2 What does an option of the retry package do when it is given a value it cannot use?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It returns an error, and New stops and returns that error with a zero Policy

    An option is a func(*Policy) error, so it can say why it cannot apply.

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.

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.

  1. Question 1 of 9 What is the zero value of a sync.Mutex?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: A mutex that nobody holds, ready to lock

    Waitlist relied on it: its zero value held a mutex that nobody had locked.

  2. Question 2 of 9 A Tally type holds counts map[string]int, and var t Tally; t.Add("go") panics. What keeps the zero value usable?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: Add makes the map when it is nil, before writing

    A nil map can be read but not written, so a type with a map field allocates it in the method that writes.

  3. Question 3 of 9 type Log struct { mu sync.Mutex; buf bytes.Buffer } has no constructor. Is var l Log ready to use?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: Yes; both fields are ready at zero, so the struct is too

    A struct of ready-at-zero fields is ready at zero.

  4. Question 4 of 9 By Go convention, what is the constructor of a type Server called?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: NewServer, or New when the package holds one main type

    Go constructors are ordinary functions, so a type can have more than one.

  5. Question 5 of 9 NewServer(addr string) gets an empty address. What should it return?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: A nil *Server and an error that says the address is empty

    NewRetryPolicy returned an error that named the bad value, retries must be 0 or more, got -1.

  6. Question 6 of 9 Why do unexported fields and a constructor work together?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: Other packages cannot set the fields, so the constructor's checks cannot be skipped by a literal

    The package is the boundary: inside it, its own code keeps the rules; outside, only its exported names are usable.

  7. Question 7 of 9 In the retry package, what is an Option?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: A function, func(*Policy) error, that changes one setting or says why it cannot

    WithRetries(4) returns such a function, with the value 4 captured inside it.

  8. Question 8 of 9 What does this print?

    Read the code, then choose one answer.

    p, err := retry.New()
    fmt.Println(p, err)
    Show the answer: Question 8 of 9

    Answer: retries 2, first after 100ms <nil>

    The options program printed retries 2, first after 100ms [100ms 200ms] <nil> for retry.New().

  9. Question 9 of 9 A library client has 12 settings, and most callers change none of them. Which design suits it?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: Functional options, so callers name only the settings they change

    Options suit libraries with many settings that callers rarely change; a Config is the other common answer.

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:

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.