How do you make sure a panic never escapes your Go package's exported functions?
answer
- list what panics on caller data first
- validate at the door, assume below
- comma-ok instead of a bare assertion
- zero values are always constructible
- fuzz what parses bytes or strings
basics
~20 sKnow the operations that panic on caller-controlled data, guard them at the entry of every exported function and return errors instead, then prove it with adversarial tests over zero values and fuzzing. Document any panic you deliberately keep.
solid answer
~50 sI treat it as a property to prove, not a habit. First I enumerate what can panic on data a caller controls: index or slice out of range, a write to a nil map, dereferencing a nil pointer argument or a zero-value struct field, an unchecked type assertion, divide by zero, and any `Must` helper reached with dynamic input. Then I validate at the door of each exported function and convert every one of those into a returned error with context, so unexported code below may assume its invariants hold. Then I prove it: table tests that pass the zero value of every argument (nil map, nil slice, nil pointer, wrong dynamic type) and a fuzz target for anything parsing bytes or strings, since an unexpected panic fails those automatically. Anything I keep goes in the doc comment on the exported name.
code
go · 12 lines// Exported: the caller controls both arguments, so nothing here may panic.
func (r *Rule) Apply(fields map[string]string, v any) (bool, error) {
s, ok := v.(string) // comma-ok; a bare v.(string) panics on a mismatch
if !ok {
return false, fmt.Errorf("rule %s: want string, got %T", r.Name, v)
}
if fields == nil {
return false, errors.New("rule: fields map is nil")
}
fields["last"] = s // a write to a nil map panics; the guard above is why
return r.pattern.MatchString(s), nil
}go deeper
Learn the short list of operations that panic, especially writing to a nil map and a bare type assertion, and the comma-ok form that avoids the second one.
Explain where validation belongs: once at the entry of the exported function, so internal code can assume its invariants and any internal panic really does mean a bug.
Demonstrate proof rather than intent. Talk about adversarial table cases over zero values and a fuzz target, and about documenting the panics you consciously keep.
Frame it as a contract your importers depend on: state the no-panic guarantee in package docs, decide what enforcement belongs in CI, and price the plumbing it costs.
### Why this is a design task, not a habit A panic that escapes an exported function is a crash your importers inherit. They cannot see it in your signature, they did not opt into it, and in a server it usually surfaces as a process restart rather than a failed request. So "my package's exported surface does not panic on caller data" is a property you should be able to argue for deliberately, the same way you argue that a type is concurrency-safe. ### Step one: know what actually panics Go's runtime panics are a short, fixed list, and every one of them can be triggered by data a caller hands you: - **Index or slice out of range** — `s[i]` or `s[a:b]` where the bounds came from the caller. - **A write to a nil map** — reading a nil map is fine and yields the zero value with `ok == false`, but assigning to a key panics. A struct field of map type that the caller left zero is nil. - **A nil pointer dereference** — a `*T` argument, or a pointer field of a struct the caller built with a literal instead of your constructor. - **An unchecked type assertion** — `v.(string)` panics when the dynamic type differs; `s, ok := v.(string)` does not. - **Integer divide or modulo by zero**, and `make([]T, n)` with a negative or absurd `n`. - **A `Must` helper on caller-supplied input** — the same defect as above, wearing a library name. - **A second `Close`** on a channel your exported `Close` closes, if the type allows being closed twice. ### Step two: guard at the door The fix for each is mechanical, and it belongs at the *entry* of the exported function rather than scattered through the internals: ```go func (r *Rule) Apply(fields map[string]string, v any) (bool, error) { s, ok := v.(string) if !ok { return false, fmt.Errorf("rule %s: want string, got %T", r.Name, v) } ... } ``` Validate once, convert every invalid state into a returned error with enough context to name the argument, and let the unexported code below assume the invariants hold. That split is what makes internal `panic` calls on impossible states defensible: they can only fire if *your* validation is wrong, never because a caller was careless. Watch particularly for zero values. Go has no non-nullable types, so `var r Rule` is always a legal value someone can construct without your constructor. Decide what a zero value of every exported type does and make it either usable or explicitly rejected. ### Step three: prove it with tests Reading the code is not evidence. Two techniques carry the weight: - **Adversarial table tests.** For each exported function, add cases for the zero value of every argument: nil pointer, nil map, nil slice, empty string, zero-length input, and a value of the wrong dynamic type where the parameter is `any` or an interface. A panic fails the test, so these cases need no special assertion machinery. - **Fuzzing.** For anything that parses bytes or strings, a fuzz target (`func FuzzX(f *testing.F)`, run with `go test -fuzz`) is exactly the tool: the corpus explores index arithmetic and parser edges far past what you would enumerate by hand, and an unexpected panic is a failing case with a minimised reproducer written to disk. `go vet` catches a related family (`Printf` argument mismatches, lost cancel functions, unreachable code) and belongs in the same gate, though it does not prove absence of panics. ### Step four: document what remains Some panics are contract, not bug: a method that panics when called on a value the package requires you to build with its constructor, or an `Add` on a type documented as not safe after `Close`. If you keep one, say so in the doc comment on the exported name, in the standard library's own voice — "It panics if the pattern cannot be parsed." An undocumented panic is a defect; a documented one is an API decision a caller can plan around. ### Where a recover fits, and where it does not A deferred recovery at a genuine process boundary is a backstop for the bugs you did not find, and it is a reasonable operational choice for a long-running server. It is not a substitute for any of the above, and putting one inside a library's exported functions is worse than useless: it hides a defect from the importer, returns a value derived from corrupted state, and takes the crash decision away from the process that owns it. Design the surface not to panic; let the process owner decide what to do about the panics that remain.
- Which everyday Go operations panic on data a caller controls?Index or slice expressions out of range, assigning to a key of a nil map, dereferencing a nil pointer, an unchecked type assertion such as `v.(string)`, integer divide or modulo by zero, and `make` with a negative length. Reading a nil map or appending to a nil slice, by contrast, are both fine.
- Why not simply wrap every exported function in a deferred recovery?Because it hides the defect from the importer and returns a value derived from state you already know is corrupt. It also takes the crash decision away from the process that owns it. Design the surface not to panic; whether to install a last-resort net is the application's call, not the library's.
- How do you decide whether to document a remaining panic instead of removing it?If it can only fire when the caller broke a contract the type system cannot express — using a value the package requires you to build with its constructor, for instance — keep it and state it in the doc comment on the exported name. If a caller can reach it with ordinary data, it is a bug, not a contract.
saying these in an interview costs you the question
- Relies on a blanket recover instead of validating inputs
- Assumes a zero-value struct can never reach an exported method
- Uses a bare type assertion on an any parameter
- Thinks reading from a nil map panics the way writing does
- Leaves an exported panic undocumented because it is unlikely