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.Timeis year 1, not epoch; check witht.IsZero(). - Copying a struct after use when it contains a mutex/WaitGroup — zero value usable ≠ copyable (
go vetcopylocks).
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
= Nassignments. - 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
var s []intvss := []int{}— differences, and which do you return? — Both havelen 0and work withappend/range/len. Differences: nil slice has a nil data pointer; JSON-marshals tonullvs[];s == nildiffers;reflect.DeepEqualdistinguishes 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.- 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, sinceiotais 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. - 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]]