Your function returns nil on the success path, the caller checks err != nil, and the check fires anyway. What happened?
An interface value carries a type alongside a pointer, so an interface holding a nil pointer of a concrete type is itself not nil. Returning a typed nil pointer into an error return produces a value that is non-nil to every caller while being nil to the function that produced it.
What the interviewer is scoring
- Does the candidate know an interface value is a type and a value rather than one word
- Whether they can say why comparing the interface to nil is false when the pointer is nil
- That the fix is the declared return type, not an extra check at the call site
- Whether they recognise the same shape in a nil map read against a nil map write
- Does the candidate connect this to why errors.Is exists rather than comparing directly
Answer
The shape of the bug
It is almost always this, and once you have seen it once you spot it by the signature alone.
type ValidationError struct{ Field string }
func (e *ValidationError) Error() string { return "invalid " + e.Field }
// The bug is in the return type. This says "an error", and the value
// returned on success is a nil *ValidationError, which is not nothing.
func validate(input string) *ValidationError {
if input == "" {
return &ValidationError{Field: "input"}
}
return nil
}
func handle(input string) error {
// Assigning a nil *ValidationError into an error interface produces a
// non-nil interface holding a nil pointer.
return validate(input)
}
func main() {
if err := handle("fine"); err != nil {
// Reached. err != nil is true, and err.Error() panics.
}
}
Nothing here is a compiler bug or an obscure corner. Every line follows from what an interface value is.
An interface value is two words
A variable of a concrete pointer type holds one thing: an address, which may be zero. A variable of an interface type holds two: a pointer to type information, and a pointer to the data. The interface is nil only when both are unset — which is to say, when nothing has ever been assigned to it.
Assigning a nil *ValidationError sets the first word to *ValidationError and
the second to zero. The type word is populated, so the interface is not nil. It
is an interface that definitely holds something, and the something it holds
happens to be a nil pointer.
That is why err != nil is true. The comparison is asking about the interface,
not about the pointer inside it, and the interface is genuinely not empty.
Two consequences follow. Calling a method on it does not immediately panic —
method dispatch works fine, because the type word tells the runtime which
Error to call — and it panics only when that method dereferences its nil
receiver. And a method with a value receiver on a nil pointer panics before the
body runs at all, while a method that never touches the receiver works
perfectly, which is why this bug can lurk for months in code whose error type
happens to have a constant message.
Fix the signature, not the call site
The tempting fix is defensive and wrong:
// Wrong. It papers over one call site and leaves the trap for the next.
if err := handle(input); err != nil && err.(*ValidationError) != nil {
The correct fix is to stop producing the value. A function that reports failure
should return error, not a concrete pointer type:
// Returns the interface directly, so the success path returns a genuine
// nil interface rather than a typed nil wrapped in one.
func validate(input string) error {
if input == "" {
return &ValidationError{Field: "input"}
}
return nil
}
The rule generalises: never return a concrete error type from a function
whose result is going to be assigned to error. Linters catch the common
form, and knowing why the linter is right is what an interviewer is after.
If you genuinely need a concrete type — a constructor returning
*ValidationError for a caller that will inspect its fields — then the caller
must convert deliberately rather than assigning through an error return, and
the function must not be the one filling in an error slot.
The same mistake wearing other clothes
Go has a small family of these, all arising from a value being more structured than it looks.
A nil map reads fine and returns the zero value, and panics on write. So a struct with an uninitialised map field works in tests that only read it.
A nil slice appends fine, because append allocates when there is no
backing array. So nil and empty behave identically for most purposes, and
differ when you compare them or serialise them to JSON, where one becomes
null and the other [].
A nil channel blocks forever on both send and receive, which is either a
deadlock or, in a select, a deliberately disabled case — one of the few
places where the behaviour is a feature.
The unifying point is that Go's zero values are usable, which is a genuine strength, and the price is that "not initialised" and "deliberately empty" are frequently the same bit pattern.
Why this is on the interview
It is a good question because it cannot be answered from documentation you skimmed. A candidate who says "the interface has a type word" has a model of the runtime; a candidate who says "you should check for nil differently" has met the symptom and not the cause. It also has a real production shape — the wrapped error that is always non-nil turns every success into a logged failure, and the handler that panics on a nil receiver takes down a request that succeeded.
An interface is nil only when it holds no type. A nil pointer is something, and an interface holding something is not nothing.
Likely follow-ups
- Rewrite the function so the bug is impossible rather than merely absent.
- Why does a nil map read work while a nil map write panics?
- How would errors.As behave against this value?
- Where else does Go compare a two-word value in a way that surprises people?
Related questions
- Go gives you no project layout and no dependency-injection container. How do you structure a service so it stays testable as it grows?mediumAlso on interfaces and golang5 min
- Several producers write to one channel and one consumer reads it. Who closes the channel?mediumAlso on golang4 min
- Design a parking lot system — you have 90 minutes, working code at the end.hardAlso on interfaces5 min
- Memory keeps climbing in a Go service under load. How do you find out why, and what can you actually tune?hardAlso on golang5 min
- A request fails but your Express error-handling middleware never runs. How do you work out why?mediumAlso on error-handling3 min
- Walk me through the middleware chain of an Express service. What has to come before what, and why?mediumAlso on error-handling4 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: concept7 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: concept6 min