- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You write a new type. First instinct from every other language you
have used: give it a constructor. NewThing(), wire up the fields,
return a pointer. Then every caller has to remember to call it, every
test has to call it, and the day someone writes var t Thing instead
of NewThing(), the code compiles and blows up at runtime.
Go's standard library mostly does not work that way. You can write
var mu sync.Mutex and lock it. You can write var b bytes.Buffer
and write to it. No constructor, no init step, no nil check. The zero
value is ready to go. That is a deliberate design habit, and it is one
of the cheapest ways to make a Go type harder to misuse.
The zero value is not nothing
In Go, every variable you declare without an initializer gets the zero
value for its type. Numbers are 0, strings are "", pointers,
slices, maps, and channels are nil, and a struct is the zero value
of every field, recursively. There is no "uninitialized" state the way
there is in C. var x int is 0, guaranteed by the spec.
The design question that follows: when someone writes var t YourType
and uses it immediately, does it work, or does it panic? If it works,
you have removed a whole class of "you forgot to call the
constructor" bugs before they can exist.
The two poster children live in the standard library.
package main
import (
"bytes"
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
mu.Lock()
mu.Unlock()
var b bytes.Buffer
b.WriteString("works with no ")
b.WriteString("constructor")
fmt.Println(b.String())
}
Neither sync.Mutex nor bytes.Buffer has a New function. You
never call one. The zero value is the usable value. That is not an
accident of implementation; it is the whole point of how those types
are laid out.
Why bytes.Buffer can pull it off
Look at the trick. bytes.Buffer holds its data in a []byte field.
The zero value of a slice is nil, and append on a nil slice
allocates a fresh backing array on first write. So the buffer does not
need an "allocate my storage" step. The first WriteString allocates
lazily, and every write after that grows the slice.
A stripped-down version makes the pattern obvious.
type Buffer struct {
buf []byte // nil is fine, append handles it
}
func (b *Buffer) Write(p []byte) (int, error) {
b.buf = append(b.buf, p...)
return len(p), nil
}
func (b *Buffer) String() string {
return string(b.buf)
}
var b Buffer gives you a buf that is nil. The first Write
appends to nil, which Go turns into a real allocation. No panic, no
constructor. The nil slice is already a working empty buffer.
sync.Mutex does the same thing from the other direction: its zero
value is the unlocked state. The struct is a couple of integer fields,
and all-zero means "nobody holds this lock." Locking an unlocked mutex
is exactly what you want to start with.
The three moves that make it work
Reusable habits fall out of those two examples.
Lean on nil where nil already behaves. A nil slice has length
zero and appends fine. A nil map reads fine (missing key returns the
zero value). A nil channel blocks forever, which is occasionally
useful in a select. If your field is a slice you only ever append
to and range over, the zero value is already correct. Do not force a
make in a constructor you did not need.
type Tags struct {
items []string // nil reads and ranges fine
}
func (t *Tags) Add(s string) { t.items = append(t.items, s) }
func (t *Tags) All() []string { return t.items }
var tags Tags; tags.Add("go") works. No NewTags().
Pick zero-friendly defaults on purpose. When a field's meaning is
"how many" or "which mode," arrange the type so that 0 is the
default you would have set anyway. A retry policy where 0 means "no
retries" needs no constructor. A timeout where 0 means "use the
package default" reads cleanly. You are choosing the numbering so the
zero value lands on the sane option.
type RetryPolicy struct {
MaxRetries int // 0 == no retries, a fine default
Backoff time.Duration // 0 == caller decides later
}
func (p RetryPolicy) Attempts() int { return p.MaxRetries + 1 }
var p RetryPolicy means one attempt, no retries. That is a defensible
default.
Guard the lazy path, not the constructor. If a field genuinely
must be built before use (a map you write into, say), build it lazily
on first access instead of demanding a constructor up front. A nil map
panics on write, so this is the one case where the zero value bites
you. A small guard method fixes it.
type Counter struct {
counts map[string]int // nil: reads ok, writes panic
}
func (c *Counter) Inc(key string) {
if c.counts == nil {
c.counts = make(map[string]int)
}
c.counts[key]++
}
var c Counter; c.Inc("hit") works because Inc builds the map the
first time. The caller never learns there was a lazy init.
When you still need a constructor
This is a habit, not a religion. Some types cannot have a working zero
value, and pretending otherwise is worse than a constructor. Reach for
NewThing() when any of these is true.
A field has no sane zero. If your type wraps a *sql.DB, an
*http.Client, or a logger that must exist, the zero value is a nil
pointer that panics on first use. There is nothing lazy to do here; the
dependency comes from outside. Take it in a constructor and store it.
type UserRepo struct {
db *sql.DB
}
func NewUserRepo(db *sql.DB) *UserRepo {
if db == nil {
panic("nil db")
}
return &UserRepo{db: db}
}
Invariants must hold from the first line. If two fields have to
agree (a ring buffer whose capacity must match its backing array, a
value that must be validated), a constructor is where you enforce it.
The zero value cannot express "these fields are consistent" on its own.
The zero value would be a silent trap. If var t Thing compiles
and runs but does the wrong thing quietly (connects to localhost
because the address field defaulted to ""), that is worse than a
panic. Force the constructor so the mistake cannot happen.
The standard library does exactly this split. bytes.Buffer and
sync.Mutex have no constructor because their zero value is honest.
sync.NewCond and http.NewRequest exist because those types have a
field with no usable zero: a sync.Cond needs a Locker, and an
http.Request needs a method and a URL. The library is not being
inconsistent; it is asking the same question per type and answering it
honestly.
The one that catches people: copying after use
There is a tax on the zero-value habit, and it shows up with
sync.Mutex. A usable zero value invites you to declare the type
inline and pass it around by value, which is fine until the type holds
state that must not be copied. A mutex is the classic case: copy a
locked mutex and you now have two mutexes and a data race.
type Registry struct {
mu sync.Mutex
items []string // nil appends fine, zero value usable
}
// BUG: value receiver copies the mutex
func (r Registry) Add(s string) {
r.mu.Lock()
defer r.mu.Unlock()
r.items = append(r.items, s)
}
The zero value of Registry is usable, but the value receiver copies
mu on every call, so the locking protects nothing. Use a pointer
receiver, and let go vet back you up.
func (r *Registry) Add(s string) {
r.mu.Lock()
defer r.mu.Unlock()
r.items = append(r.items, s)
}
go vet ships a copylocks analyzer that flags copies of any type
containing a sync.Locker. Run go vet ./... and it catches the
common cases. The design rule that keeps you safe: types with a
usable zero value that also hold non-copyable state should be used
through pointers, and their methods should take pointer receivers.
The habit, in one line
Before you write NewThing(), ask what var t Thing does. If it
already works, you just saved every caller a step and closed a bug
class. If it panics or lies, write the constructor and mean it. The
standard library made that call type by type, and copying the way it
thinks is most of what "idiomatic Go" turns out to mean here.
Getting the zero value right sits at the boundary between two things:
knowing how Go lays out slices, maps, and structs in memory, and
knowing where a dependency belongs so its absence is caught at the
edge instead of deep inside a handler. The Complete Guide to Go
Programming digs into the runtime and memory-model side of why nil
slices and zero structs behave the way they do. Hexagonal
Architecture in Go is about the other half: keeping the types that
truly need a constructor at the right boundary, so the zero-value
types stay simple in the core.

Top comments (0)