Make the Zero Value Useful and Design Your Types Around It
Design Go types so var x T works with no constructor. See standard library examples, lazy initialization, the nil map trap and when a constructor is right.

The Go proverb is "make the zero value useful", and it is the design idea that most separates idiomatic Go types from a straight port of an object-oriented design. In most languages an object has to be constructed before it can be used, and constructors are where defaults, validation and initialization live. In Go the language gives every variable a zero value for free, so a type designed around that value needs no constructor, no Init call and no "is this object ready" flag. This article shows the standard library examples, a type designed both ways, the lazy initialization technique that makes most types work, and the cases where a constructor is correct. It builds on zero values in Go, the proverbs and structs, and sits in the Go basics path.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
What the standard library does
Some of the most used types in the standard library cost nothing to create:
package main
import (
"bytes"
"fmt"
"strings"
"sync"
)
func main() {
var mu sync.Mutex
var wg sync.WaitGroup
var buf bytes.Buffer
var sb strings.Builder
var once sync.Once
for i := range 3 {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
fmt.Fprintf(&buf, "worker %d\n", i)
mu.Unlock()
once.Do(func() { sb.WriteString("first worker started the show") })
}()
}
wg.Wait()
fmt.Println(buf.Len() > 0, sb.String())
}true first worker started the showNone of the five variables was constructed. A sync.Mutex starts unlocked, a WaitGroup at zero, a bytes.Buffer and a strings.Builder empty with room to grow, and a sync.Once unfired. Everything the program does with them works from the zero state. The authors of those types chose their fields so that the empty state is the starting state, and the saving is on every call site in every program: no NewMutex, no error to check, no forgotten initialization. It is also why these types embed cleanly in structs. A struct with a sync.Mutex field is usable as a zero value as long as its other fields are.
A type designed twice
Take a set of strings. The first design thinks like a class, with a constructor that creates the map:
package main
import "fmt"
type SetA struct {
items map[string]struct{}
}
func NewSetA() *SetA {
return &SetA{items: make(map[string]struct{})}
}
func (s *SetA) Add(v string) { s.items[v] = struct{}{} }
type SetB struct {
items map[string]struct{}
}
func (s *SetB) Add(v string) {
if s.items == nil {
s.items = make(map[string]struct{})
}
s.items[v] = struct{}{}
}
func (s *SetB) Has(v string) bool {
_, ok := s.items[v]
return ok
}
func (s *SetB) Len() int { return len(s.items) }
func main() {
a := NewSetA()
a.Add("x")
var b SetB
b.Add("x")
b.Add("y")
fmt.Println(b.Has("x"), b.Has("z"), b.Len())
var empty SetB
fmt.Println(empty.Has("anything"), empty.Len())
}true false 2
false 0SetA works only if the caller remembers NewSetA. If they write var a SetA and call Add, the program panics with assignment to entry in nil map, and the cause is two files away from the symptom, as the nil map panic describes. SetB has no constructor. Its Add creates the map the first time it is needed, Has and Len read from a nil map, which is legal, and an empty SetB answers false and 0 without any setup. The cost is one nil check in Add, which is a pointer comparison and not worth measuring.
The rule that makes this work is the asymmetry of nil maps and slices: reading and ranging are fine, and writing needs an allocation. Put the allocation in the one method that writes, or in a private helper that every writing method calls, and the zero value is ready.
What makes a field zero-safe
Walk the field types of your struct and ask what each looks like at zero:
| Field type | Zero state | Usable? |
|---|---|---|
| Numbers, bools, strings | 0, false, "" | Yes, if those are meaningful |
| Slice | nil, length zero | Yes, append allocates |
| Map | nil | Reads yes, writes need make |
| Pointer | nil | Only if nil means "absent" and you check |
| Channel | nil | Blocks forever; make it in a constructor or lazily |
| Function | nil | Panics when called; default it lazily |
| Interface | nil | Panics on method calls; default it lazily |
sync.Mutex, sync.Once, sync.WaitGroup | Ready | Yes, but not copyable after use |
time.Time | Year 1 | Valid, and IsZero tests for it |
The ones that need care are maps, channels, functions and interfaces. For function and interface fields, a useful default makes the zero value work: a Logger field that is nil can fall back to a no-op logger inside the method that uses it, and a Clock func() time.Time field can default to time.Now. That lets a test inject a fake and lets production code leave the field alone.
Defaults that are not zero
A struct whose sensible default is not zero, a timeout of 30 seconds or a retry count of 3, has a choice. Either make zero mean "use the default", which is how http.Server treats most of its fields, or add a constructor. The first is the Go idiom:
type Client struct {
Timeout time.Duration // zero means 30 seconds
}
func (c *Client) timeout() time.Duration {
if c.Timeout == 0 {
return 30 * time.Second
}
return c.Timeout
}The cost of this approach is that you cannot express "no timeout" with zero, so the type needs another way, a negative value or a separate boolean, and that goes in the documentation. The standard library's net/http.Client documents that a zero Timeout means no timeout, which is its own choice. Whatever the rule is, write it down, because the default for zero is the thing a reader cannot see from the struct definition.
When a constructor is right
A zero value cannot be useful when the type owns something that must be created. A struct wrapping an open file, a database pool, a connection or a running goroutine has an invariant, "this is open", that zero cannot satisfy. Give it a New function that returns a ready value, and an error if it cannot, and say in the doc comment that the zero value is not usable. Types with required parameters, such as a client that cannot work without a base URL, are the same case. And types whose methods rely on invariants between fields, a buffer with a length that must match its slice, need to guard them in one place.
For these, constructor functions in Go lists the conventions, and functional options handles the case with many optional settings. The two styles coexist: a type can offer a useful zero value for the simple case and a constructor for the configured one, as bufio.Scanner is not usable at zero and strings.Builder is.
Testing the claim
If your documentation says the zero value is ready, prove it with a test that does nothing but declare one and use it:
package zero
type Set struct {
items map[string]struct{}
}
func (s *Set) Add(v string) {
if s.items == nil {
s.items = make(map[string]struct{})
}
s.items[v] = struct{}{}
}
func (s *Set) Has(v string) bool {
_, ok := s.items[v]
return ok
}
func (s *Set) Len() int { return len(s.items) }package zero
import "testing"
func TestZeroValueIsReady(t *testing.T) {
var s Set
if s.Has("a") || s.Len() != 0 {
t.Fatal("empty set should be empty")
}
s.Add("a")
if !s.Has("a") || s.Len() != 1 {
t.Fatal("add on a zero value failed")
}
}=== RUN TestZeroValueIsReady
--- PASS: TestZeroValueIsReady (0.00s)
PASS
ok example.test/lab 1.238sThe test is two lines of setup and fails loudly if someone later adds a field that breaks the claim. Make it a habit for any type whose zero value you promise. If it is hard to write, that is information: the type probably has hidden initialization that deserves either a constructor or a redesign. The idea is stated in Effective Go's discussion of allocation, where the zero-value design of bytes.Buffer and sync.Mutex is the example, and the Go proverbs list it among the shortest.


