When a Go type must own a background goroutine and a channel, why can't its zero value be usable, and what do you expose instead?
answer
- the failure is a hang, not a crash
- no goroutine exists to serve the channel
- who calls Close?
- unexported fields make the constructor the only path
- the doc comment is the whole contract
basics
~20 sThe zero value channel field is nil, so sends and receives block forever and no goroutine is running to serve them. Export a New that makes the channels and starts the loop, and document that.
solid answer
~50 sA nil channel blocks forever in both directions, so a declared-but-unbuilt value of such a type does not crash — it hangs, and the goroutine that touched it disappears from the working set with no panic and no error. That silent failure is why lazy repair is a bad fit here: there is also no goroutine running, and starting one from inside an arbitrary method gives you no place to report failure and no owner for its lifetime. The right shape is an exported constructor that makes the channels, starts the loop and returns a pointer, paired with a `Close` or `Stop` so the caller owns the shutdown. Keep the channel fields unexported so nobody outside the package can assemble a plausible-looking broken value with a struct literal, and put the contract in the type's doc comment: the zero value is not usable, call `NewWatcher`. If you want it to fail loudly anyway, a guard that panics with "use NewWatcher" beats an unexplained hang.
code
go · 19 lines// Watcher delivers events until Close is called.
// The zero value is not usable; call NewWatcher.
type Watcher struct {
events chan string
done chan struct{}
}
func NewWatcher() *Watcher {
w := &Watcher{
events: make(chan string),
done: make(chan struct{}),
}
go w.loop() // reads w.done, emits on w.events
return w
}
func (w *Watcher) Events() <-chan string { return w.events }
func (w *Watcher) Close() { close(w.done) }go deeper
Remember that a nil channel is not an empty channel: sending to or receiving from one blocks forever. A type holding channels needs make before anything can use it.
Explain what the constructor must do — create each channel, start the loop, return a pointer — and why unexported fields plus a doc comment are how the requirement is communicated in a language with no private constructors.
Argue about failure modes: rank a working zero value first, a documented constructor second, and a panic with the fix in its message third, with a silent hang as the outcome you are designing away. Mention who calls Close and how you would spot the leak with a goroutine profile.
Own the boundary contract. Whether a published type is declarable or must be constructed is something dependent teams build on and you cannot quietly change, so decide it deliberately, apply it consistently, and pair every goroutine-owning type with an explicit shutdown story.
## Why this type is different from the map case A struct whose only awkward member is a map can usually be rescued: create the map on first write and the zero value works again. A type that owns a running goroutine cannot be rescued the same way, for two separate reasons. **The failure is silent.** A nil map panics on write — loud, immediate, with a stack trace pointing at the line. A nil channel does the opposite: a send blocks forever, a receive blocks forever, and a `select` case on a nil channel is simply never ready, which is the standard trick for disabling a case. So a caller who writes `var w Watcher` and then `w.events <- e` gets a goroutine that parks and never returns. Nothing is logged. The only symptom is a goroutine count that climbs, and the only diagnostic that finds it is a goroutine profile or a `SIGQUIT` stack dump. A design whose misuse produces a hang is worse than one whose misuse produces a panic. **There is nothing running.** Even if you lazily created the channel, the loop that drains it does not exist. Starting it from inside a method means an ordinary call now spawns a goroutine with no obvious owner, no error return if setup fails, and no defined point at which it stops. Lifetime is the part callers must be told about, and the type declaration cannot tell them. ## The shape to expose ```go // Watcher delivers events until Close is called. // The zero value is not usable; call NewWatcher. type Watcher struct { events chan string done chan struct{} } func NewWatcher() *Watcher { w := &Watcher{ events: make(chan string), done: make(chan struct{}), } go w.loop() return w } func (w *Watcher) Events() <-chan string { return w.events } func (w *Watcher) Close() { close(w.done) } ``` Four decisions are doing the work here. 1. **The constructor is the only correct path**, and it returns a pointer because the value owns a goroutine and must not be copied around as a value. 2. **The channel fields are unexported.** A caller in another package can still write `Watcher{}`, but cannot construct something that looks half-built, and cannot substitute their own channel. Unexported mandatory fields are how you make "you must use the constructor" close to enforceable without a language feature for it. 3. **The doc comment states the contract.** Nothing in a Go type declaration says whether zero works. The standard library's habit of writing either "the zero value is an empty X ready to use" or "call NewX" is the entire mechanism, and omitting it is the actual defect when someone later declares the type and hangs. 4. **There is a `Close`.** If the type starts a goroutine, the caller must be able to stop it, or every construction is a leak. Handing back a receive-only `<-chan string` from an accessor keeps the direction honest as well. ## When you want it to fail loudly instead If you cannot prevent misuse structurally — the type has to live in another package's struct, say — an explicit guard is defensible: ```go func (w *Watcher) Send(e string) { if w.events == nil { panic("watcher: Watcher must be created with NewWatcher") } ... } ``` A panic carrying the fix is strictly better than a hang, and it is the one case where deliberately panicking in a library is widely accepted: the program is misconfigured at construction, not failing at run time on data. ## The judgment being tested The interviewer is not asking whether you can spell `make(chan T)`. They are asking whether you rank failure modes when you design a type. The order to argue for is: make the zero value genuinely work if the members allow it; if they do not, force construction and document it; if the type can still be declared into existence, make that misuse crash with a message rather than hang. Applying a lazy-init reflex to a type that owns concurrency inverts that order, and the resulting bug shows up as a leaked goroutine in production rather than as a test failure.
- Why is a hang a worse design outcome than a panic here?A panic names the line and stops the program during the first test run. A blocked send on a nil channel produces no output at all: the goroutine parks forever, the count creeps up, and you need a goroutine profile or a stack dump to see it. Misuse that fails silently reaches production; misuse that crashes usually does not.
- How do unexported fields help enforce the constructor?Code in other packages cannot set them, so the only way to obtain a value with working internals is the exported constructor. Callers can still write the empty literal, which is why the doc comment and, optionally, a guarded panic still matter — but they can no longer build something that looks deliberately configured and is not.
- What else does the type owe the caller once its constructor starts a goroutine?A way to stop it, and a documented statement of when it stops. Without a Close or Stop — or a context the constructor accepts — every construction leaks a goroutine, and callers that create one per request will accumulate them until the process dies. Owning a goroutine and owning a shutdown are the same responsibility.
saying these in an interview costs you the question
- Says a send on a nil channel panics like a nil map write
- Lazily starts a background goroutine inside an ordinary method
- Exports the channel fields so callers can wire them
- Provides a constructor but no way to stop the goroutine
- Leaves the zero-value contract out of the doc comment
- Copies the value around after construction