skip to content

Your Go client library takes a Config with a Timeout time.Duration — how do you tell 'unset' from an explicit zero?

level: seniorimportance: should knowfreq 44%

answer

  1. Go structs have no absent state
  2. Config{} equals Config{Timeout: 0}
  3. a sentinel, a pointer, or an option function
  4. nil survives JSON decoding, zero does not
  5. a zero http.Client.Timeout means none at all

basics

~20 s

From the value alone you cannot: Go zeroes every field, so an omitted Timeout and one explicitly set to 0 are identical bits. Either define zero to mean 'use the default', or make the field a pointer, or accept option functions.

solid answer

~50 s

There is no absent state in a Go struct: `Config{}` and `Config{Timeout: 0}` are the same value, so the field cannot answer the question on its own. Three shapes solve it. Define zero to mean *use the default*, apply that default where the value is read, and add an explicit constant for the genuine no-timeout case. Or make the field `*time.Duration`, where nil is unset and a non-nil pointer is a deliberate choice — which also matches JSON decoding, since an absent key leaves the pointer nil. Or take option functions applied over an already-defaulted struct, so only what the caller names changes. Whichever you pick, the doc comment must state what zero means, because the failure is silent: `http.Client`'s zero `Timeout` means *no timeout*, so a caller who forgot the field gets a request that hangs rather than one that fails.

code

go · 17 lines
go
const defaultTimeout = 30 * time.Second
const noTimeout = time.Duration(-1)

type Config struct {
	// Timeout of 0 selects defaultTimeout; use noTimeout to disable it.
	Timeout time.Duration
}

func (c Config) resolvedTimeout() time.Duration {
	switch c.Timeout {
	case 0:
		return defaultTimeout
	case noTimeout:
		return 0
	}
	return c.Timeout
}

go deeper

for a junior

Know that a struct field you never set is not missing — it holds its type's zero value, so a Timeout you forgot reads as 0 rather than raising any error.

for a middle

Explain why Config{} and Config{Timeout: 0} are the same value, and show the two common ways out: define zero as the default, or make the field a pointer so nil means unset.

for a senior

Bring the production consequence — a zero http.Client.Timeout means no timeout, so a forgotten field hangs instead of failing — and describe the test you would write against a bare Config{}.

for a principal

Decide once, across every package your organisation ships, what a zero field means and how a caller says 'off'; then treat that meaning as frozen, because changing it after release alters behaviour for callers whose code still compiles.

## The root of the problem Go guarantees that every field of a struct is set to its type's zero value, and it offers no way to mark a field as absent, optional or defaulted in its declaration. That gives you a very pleasant property — no uninitialised memory — and takes one away: `Config{}` and `Config{Timeout: 0}` are bit-for-bit identical. Nothing downstream can distinguish "the caller did not think about this" from "the caller asked for zero". For a library author this is a design decision you make once per exported field and cannot quietly change later, because the meaning of zero is part of your public contract. ## Why it is dangerous rather than merely annoying The standard library shows the failure mode. `http.Client.Timeout` is documented so that zero means *no timeout at all*. So the caller who wrote `client := &http.Client{}` and moved on has not chosen a sensible default; they have chosen to wait forever. `http.Server`'s `ReadTimeout`, `WriteTimeout` and `IdleTimeout` behave the same way. A wrong zero taken as a real setting does not produce an error at the point of the mistake — it produces a hung request, or an unbounded resource, at 3am. So the question is not academic taste. It is: when a caller of your SDK forgets a field, do they get a safe default or a hazard? ## Shape one: zero means "use the default" Make the zero value mean the sensible thing, and normalise at the point of use or in your constructor. ```go const defaultTimeout = 30 * time.Second const noTimeout = time.Duration(-1) type Config struct { // Timeout of 0 selects defaultTimeout; use noTimeout to disable it. Timeout time.Duration } func (c Config) resolvedTimeout() time.Duration { switch c.Timeout { case 0: return defaultTimeout case noTimeout: return 0 } return c.Timeout } ``` Strengths: literals stay clean, the type has a usable zero value, and forgetting the field is safe. Cost: the caller has lost the ability to say zero directly, so you must give them a sentinel, and the sentinel must be documented on the field itself. Normalise in one place — a private accessor or a single `withDefaults` step — rather than sprinkling `if c.Timeout == 0` through the package, or the meaning of zero will drift between call sites. This shape is the right default for a field where zero is a hazard (timeouts, limits, buffer sizes) and where a genuine zero is rare. ## Shape two: a pointer field ```go type Config struct { Retries *int } ``` Now nil means unset and a non-nil pointer means the caller chose a value, zero included. This is the only shape that represents the distinction directly in the type. It is also the shape that decoding forces on you. With `encoding/json`, an absent key leaves the field untouched — so a pointer field stays nil for `{}` and points at 0 for `{"retries":0}`. With a plain `int` you literally cannot tell those apart after decoding, which matters for partial-update or patch-style payloads. Costs: an allocation and a nil check at every read, and literals get awkward because you cannot take the address of a constant inline without a helper. Keep pointer fields to the handful where absence is genuinely meaningful. ## Shape three: option functions ```go type Option func(*Config) func WithTimeout(d time.Duration) Option { return func(c *Config) { c.Timeout = d } } func New(opts ...Option) *Client { cfg := Config{Timeout: defaultTimeout} for _, o := range opts { o(&cfg) } return &Client{cfg: cfg} } ``` Defaults live in one place, and only the settings a caller actually names are overwritten, so "unset" is expressed by not calling the option at all. This also lets you add settings later without breaking any existing call site. The cost is more API surface and a config that is harder to build from a decoded file, since a file has to be translated into options. ## Shape four: an explicit presence flag A paired `Timeout time.Duration` plus `TimeoutSet bool`, or a wrapper in the style of `database/sql`'s `sql.NullInt64`, encodes presence beside the value. It is verbose and easy to get out of sync, but it survives copying and comparison better than a pointer does, and it is sometimes the right answer for a struct that crosses a serialisation boundary. ## Make the zero path testable Whatever you choose, exercise it. The bug hides in the path nobody writes a test for, because tests tend to construct fully populated structs. Add a test that builds the type the way a hurried caller will — `var c Client`, or `New(Config{})` — and asserts the behaviour you promised: that the default timeout is applied, that no request can hang, that a zero limit is not treated as "unlimited" by accident. That single test is what turns a documented convention into an enforced one. ## What to write down Every exported field whose zero value is meaningful gets a doc comment saying so, in the form the standard library uses: what zero selects, and how to express the other case. Reviewers can then check the contract instead of guessing it, and the decision stops being re-litigated per pull request.

  • What does a zero Timeout on an http.Client actually do?
    It means no timeout at all — the request can block indefinitely. That makes `&http.Client{}` a hazard rather than a safe starting point, and it is the canonical example of a zero value being read as a real setting. Any wrapper you ship around it should set a deadline of its own, or document loudly that the caller must.
  • Why does a pointer field solve this for JSON payloads specifically?
    Decoding leaves fields the payload does not mention untouched. Starting from a zero struct, an absent key leaves a `*int` field nil, while an explicit `0` in the payload allocates and points at zero. With a plain `int` both cases end as 0, so a partial-update endpoint cannot tell 'do not change retries' from 'set retries to 0'.
  • How would you test that the zero-value path behaves as documented?
    Construct the type the way a careless caller will — `var c Client` or `New(Config{})` — and assert the promised behaviour: the default timeout is in force, no operation can block forever, and a zero limit is not silently read as unlimited. Tests that always build fully populated structs never cover the path where the bug lives.
  • Can you change what zero means for an exported field after release?
    Not safely. The meaning of zero is part of the public contract, and every caller who omitted the field is relying on it. Changing zero from 'no timeout' to 'thirty seconds' silently alters behaviour for code that still compiles and still passes its own tests. Add a new field or a new constructor instead, and leave the old meaning alone.

saying these in an interview costs you the question

  • Claims a plain int field can express not-set
  • Says JSON decoding reports which keys were absent
  • Treats a zero timeout field as a safe default
  • Applies ad-hoc defaults at many call sites
  • Changes the meaning of zero on a released field