Back to Notes

Go Zero Values, Enums & Options Pattern

Zero Values & Make-the-Zero-Value-Useful

What: Every Go value is initialized: numeric 0, "", false, nil for pointers/slices/maps/chans/funcs/interfaces. Idiomatic APIs make the zero value immediately usable.

Syntax/Signature: var mu sync.Mutex — ready. var b bytes.Buffer — ready. var s []int — nil, but append, len, range all work.

type Registry struct {
	mu sync.Mutex
	m  map[string]int // nil until first write
}

func (r *Registry) Add(k string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.m == nil { // lazy init keeps zero value useful
		r.m = make(map[string]int)
	}
	r.m[k]++
}

var r Registry // usable with no constructor

How it works under the hood: All allocations (stack, heap, globals) are zeroed memory — mallocgc returns cleared spans, so zero-value semantics are free. nil slice = all-zero header (works because append handles nil); nil map = nil pointer to hmap (reads consult nothing and return zero; writes would need a bucket array ⇒ panic). See [[Go Data Structure Internals]] for the full map/slice internals this builds on.

Interview angle: "nil slice vs empty slice?" — both len 0; nil marshals to JSON null vs []; reflect.DeepEqual distinguishes them; idiomatic code treats them identically and returns nil. "Why does stdlib love zero values?" — removes constructor ceremony and a class of 'forgot to init' bugs (sync.Mutex, sync.WaitGroup, bytes.Buffer, http.Client).

Gotchas / common wrong answers:

  • Writing to a nil map panics (reading doesn't) — the #1 zero-value trap.
  • Zero time.Time is year 1, not epoch; check with t.IsZero().
  • Copying a struct after use when it contains a mutex/WaitGroup — zero value usable ≠ copyable (go vet copylocks).

Trade-offs / when NOT to use: If invariants must hold from birth (validated config, required deps), use a constructor and consider unexporting the type's fields — zero-value-usable is a design goal, not a law.

iota & Enums

What: iota is the index of the ConstSpec within a const block (0-based, incrementing per line), used to build enumerated constants; Go has no enum type — idiom is a named integer type + typed constants.

Syntax/Signature: implicit repetition: a ConstSpec with no expression repeats the previous one.

type State int

const (
	StateIdle State = iota // 0
	StateRunning           // 1 (expression repeats)
	StateDone              // 2
)

//go:generate stringer -type=State

const (
	_  = iota             // skip 0
	KB = 1 << (10 * iota) // 1<<10
	MB                    // 1<<20
	GB                    // 1<<30
)

How it works under the hood: Pure compile-time: constants are untyped-until-used arbitrary-precision values; iota resets to 0 at each const keyword and increments per line (a line with multiple names shares one iota). No runtime representation beyond the underlying int.

Interview angle: "How do you get exhaustive-switch safety Go doesn't provide?" — honest answer: you don't natively; mitigations are stringer + a default: panic, linters (exhaustive), or closed sets via unexported-type sealed interfaces.

Gotchas / common wrong answers:

  • Enums aren't closed: State(42) is a legal value — validate at boundaries (JSON decode, DB scan).
  • Inserting a constant mid-block silently renumbers everything after it — never persist iota values to disk/DB/proto without explicit = N assignments.
  • Zero value is the first constant — make it a deliberate StateUnknown/StateIdle, not accidentally "the dangerous one".

Trade-offs / when NOT to use: For wire/persisted values, assign explicit numbers (or use strings) — iota ordering is a refactoring landmine. Sealed-interface "sum types" give closedness at the cost of allocation and verbosity.

Functional Options Pattern

What: Variadic self-referential functions configure a constructor: required params positional, optional ones as Option values — extensible without breaking callers.

Syntax/Signature: type Option func(*Server) · func New(required string, opts ...Option) *Server.

type Server struct {
	addr    string
	timeout time.Duration
	logger  *slog.Logger
}

type Option func(*Server)

func WithTimeout(d time.Duration) Option {
	return func(s *Server) { s.timeout = d }
}

func WithLogger(l *slog.Logger) Option {
	return func(s *Server) { s.logger = l }
}

func NewServer(addr string, opts ...Option) *Server {
	s := &Server{ // defaults first
		addr:    addr,
		timeout: 30 * time.Second,
		logger:  slog.Default(),
	}
	for _, o := range opts {
		o(s)
	}
	return s
}

// srv := NewServer(":8080", WithTimeout(5*time.Second))

How it works under the hood: Each With... returns a closure capturing the config value (one small allocation per option, constructor-time only — irrelevant cost). Variants: type Option interface{ apply(*Server) } for extra rigor/docs, or func(*Server) error when options can fail validation.

Interview angle: "Options vs config struct?" — config struct (New(Config{...})) is simpler and fine when the zero value of every field is a sane default; options win when defaults are non-zero (you can't distinguish "caller set 0" from "caller left it unset" in a struct), when construction spans packages, or for forward-compatible public libraries. Know both and say when each — that's the senior answer.

Gotchas / common wrong answers:

  • Using options for required parameters — required args belong in the signature.
  • Exporting the config struct's fields and options — two mutation paths, one API too many.
  • Applying options before defaults, so defaults clobber caller choices.

Trade-offs / when NOT to use: Boilerplate-heavy for internal code with 2 knobs — a plain struct or extra constructor (NewServerWithTLS) is honest. gRPC/grpc.Dial is the canonical real-world example to cite.

Interview Drills

  1. var s []int vs s := []int{} — differences, and which do you return? — Both have len 0 and work with append/range/len. Differences: nil slice has a nil data pointer; JSON-marshals to null vs []; s == nil differs; reflect.DeepEqual distinguishes them. Idiomatic Go returns nil — callers must not care. If a JSON API contract requires [], that's the one place to return the empty literal.
  2. Why is inserting a new constant in the middle of an iota-based const block a dangerous refactor? — Every constant after the insertion point silently shifts to a new integer value, since iota is purely positional (per-line within the block) — any code that persisted these values to a database, wire format, or protobuf now has stored values that map to the wrong constant after the code change. Always assign explicit values for anything persisted.
  3. When would functional options be the wrong choice compared to a plain config struct? — When every field's zero value is already a sane default and there's no need to distinguish "caller explicitly set 0" from "caller left it unset" — a plain New(Config{...}) is simpler, more discoverable (all options visible in one struct literal), and avoids the closure-per-option allocation overhead, however negligible. Functional options earn their complexity specifically when defaults are non-zero or construction needs to remain extensible across package boundaries without breaking existing callers.

Cross-links

[[Go Data Structure Internals]] · [[Go Embedding & Dependency Injection]] · [[OOD Design Patterns]]