error interface — Interview Questions¶
Cross-level interview prep specific to the
errorinterface, custom error types, method sets, behavioral interfaces, and custom Is/As. Easy at the top, hardest at the bottom.
Junior¶
Q1. What exactly is the error interface?¶
Short answer: A predeclared interface with a single method, Error() string.
Stronger answer: It lives in the universe block, so you do not import it. Any type whose method set contains Error() string satisfies it. That is the entire definition; everything else (wrapping, sentinels, behavioral interfaces) is convention layered on top.
Q2. How do you make a custom error type?¶
Define a type — usually a struct — and attach a method Error() string:
type MyError struct {
Code int
Msg string
}
func (e *MyError) Error() string {
return fmt.Sprintf("err %d: %s", e.Code, e.Msg)
}
Now *MyError satisfies error. Return it where any error is expected.
Q3. Why pointer receiver and not value receiver?¶
- Avoids copying the struct on every call.
- Standard library convention (
*os.PathError,*net.OpError,*json.SyntaxError). - Pointer identity makes
errors.Iscomparisons stable: two pointers are equal only if they refer to the same instance. - Value receivers are reasonable only for empty types or named primitives (
type ErrCode string).
Q4. Can a value type satisfy error?¶
Yes, if the method has a value receiver. type ErrCode string with func (e ErrCode) Error() string works fine. Both ErrCode and *ErrCode are then in the method set, so both satisfy error.
Q5. What's wrong with this code?¶
The method is on*Foo, so only *Foo has Error() in its method set. Foo{} (value) does not satisfy error. Compiler error: Foo does not implement error (Error method has pointer receiver). Fix: var e error = &Foo{}. Q6. What's a sentinel error?¶
A package-level variable used as a known marker:
Callers compare witherrors.Is(err, ErrEmpty). Sentinels are part of the public API of a package. Q7. How do you embed the error interface in a struct?¶
The outer struct's method set inherits Error() from the embedded interface. Construct it: ve := ValidationError{error: errors.New("invalid"), Field: "email"}
fmt.Println(ve.Error()) // "invalid"
Q8. Why is embedding error sometimes useful?¶
- You compose an existing error with extra fields (
Field,Path,Code) without re-implementingError(). - You decouple "what is the message" (from the wrapped error) from "what extra data am I adding" (your fields).
Q9. What is the method set of T vs *T?¶
- Method set of
Tincludes only methods declared with receiverT. - Method set of
*Tincludes methods declared with receiverTand methods declared with receiver*T.
So pointer types have at least as many methods as value types — never fewer.
Q10. What does this print?¶
e. E has a value receiver, so the value satisfies error. The interface holds the value E{}, and calling Error() returns "e". Middle¶
Q11. What is the typed-nil interface gotcha and why does it happen?¶
type MyErr struct{}
func (*MyErr) Error() string { return "x" }
func f() error {
var p *MyErr // nil pointer
return p // returns NON-nil interface
}
*MyErr, non-nil) and data (nil). Equality with nil requires both words to be nil, so f() == nil is false. Fix: return an explicit nil from the function when there is no error.
Q12. What is a behavioral interface?¶
An interface that captures what an error can do rather than what type it is:
type Temporary interface { Temporary() bool }
type Timeout interface { Timeout() bool }
type Retryable interface { Retryable() bool }
Code can ask:
Decouples the caller from concrete error types.
Q13. How does a custom Is(target error) bool change errors.Is?¶
By default, errors.Is uses == to compare each layer to the target. If you define Is on your error type, errors.Is will call your method instead. Use it for value-equality semantics on types where pointer identity is wrong:
type StatusErr struct{ Code int }
func (e *StatusErr) Error() string { return ... }
func (e *StatusErr) Is(target error) bool {
t, ok := target.(*StatusErr)
return ok && e.Code == t.Code
}
errors.Is(myErr, &StatusErr{Code: 404}) works regardless of which instance was returned. Q14. When would you write a custom As(target any) bool?¶
Rarely. Only when you want assignment to a target type that is not the dynamic type of your error — that is, you want to adapt between two error shapes.
Example: a *WrappedDBError that exposes itself as a *DBError for callers that only know *DBError.
The default errors.As based on reflect.TypeOf().AssignableTo() covers most cases.
Q15. What happens when you compare two interface values with == if the underlying type is non-comparable?¶
Run-time panic: comparing uncomparable type .... This is specified behavior. errors.Is does this comparison, so it also panics on non-comparable errors.
Avoid slices, maps, and functions as fields in error structs. Use strings, ints, pointers, and named types.
Q16. Two error types that satisfy error:¶
type A struct{}
func (a A) Error() string { return "a" }
type B struct{}
func (b *B) Error() string { return "b" }
B{} has only a pointer receiver method, so B{} (value) does not satisfy error. Compiler error. Use &B{}. Q17. What is a stringer, and how does it interact with error?¶
fmt.Stringer is interface { String() string }. Both Stringer and error are recognized by fmt. If a value has both, Error() wins for the error interface; %s on a Stringer value uses String().
A type that implements only String() does not satisfy error. Be explicit.
Q18. How do you build an error type with both an HTTP status and a wrapped cause?¶
type APIError struct {
Status int
Msg string
Cause error
}
func (e *APIError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("api %d: %s: %v", e.Status, e.Msg, e.Cause)
}
return fmt.Sprintf("api %d: %s", e.Status, e.Msg)
}
func (e *APIError) Unwrap() error { return e.Cause }
func (e *APIError) StatusCode() int { return e.Status }
It satisfies error, has a public StatusCode() method (so a behavioral interface like interface { StatusCode() int } matches it), and supports errors.Is/errors.As via Unwrap.
Q19. When is value receiver preferable for an error type?¶
- The type has no fields (
type ErrShutdown struct{}). - The type is a named primitive (
type ErrCode string). - You want value equality so two instances with the same data compare equal.
- The cost of copying is trivial (~16 B or less).
For anything with multiple fields, pointer receiver is the default.
Q20. What does this print?¶
type E struct{ Msg string }
func (e *E) Error() string { return e.Msg }
var e1 error = &E{Msg: "x"}
var e2 error = &E{Msg: "x"}
fmt.Println(e1 == e2)
false. Two distinct *E allocations have different addresses. Interface equality requires same dynamic type and equal dynamic values; pointer equality is by address. Use errors.Is with a custom Is method or compare fields directly. Senior¶
Q21. How would you design an error type for a library used by other teams?¶
- Make the type comparable (no slices/maps as fields).
- Provide a constructor rather than expecting struct literals — lets you change shape later.
- Expose only fields callers should rely on; keep diagnostic fields lowercase.
- Implement
Unwrap()if you wrap a cause. - Implement
Is(error) boolif you have value-equality semantics forerrors.Is. - Document which sentinels and types are part of the public contract.
Q22. How do you "seal" an interface so external packages cannot implement it?¶
Add an unexported method:
Only types in the same package can satisfysealed(). Useful for exhaustive type switches and invariants. The standard database/sql package uses similar patterns. Q23. Pros and cons of a single error type with a Kind enum vs many error types?¶
Single + Kind (type Err struct{ Kind Kind; ... }): - Pros: one type to maintain, easy to switch on, less surface area. - Cons: all kinds share the same fields; cannot carry kind-specific data cleanly.
Multiple types (*NotFoundError, *ConflictError, ...): - Pros: each carries its own structured data; type assertions are precise. - Cons: many types, verbose switches, more public surface.
Real systems blend: a small number of types each with a Kind enum where the kind variants are similar.
Q24. What is "error translation," and how does it interact with custom error types?¶
Translating means converting an error from one layer's vocabulary to another's. Example: a database error becomes a domain *NotFoundError at the repository boundary; a domain error becomes an HTTP response at the handler.
Without translation, your handler does strings.Contains(err.Error(), "duplicate key") — fragile. With translation, each layer's error type stays inside its own boundary.
The custom error types you design at each layer become the interface between layers.
Q25. Why does errors.As need a pointer to the target?¶
Because it assigns the matched error into the target. Reflection requires an addressable value to write into:
Passing the value (pe) would mean errors.As could only read its type, not assign. The pointer is required. Q26. What is the difference between a sealed error type and an exhaustive type switch?¶
- Sealed: no external package can implement the interface — invariants are guaranteed at compile time.
- Exhaustive switch: a switch on a type, where the compiler (or a linter) verifies every known type is handled.
Sealing makes the type set finite; exhaustive switching uses that finiteness. Without sealing, an exhaustive switch is best-effort.
Q27. How does embedding interact with Unwrap?¶
If you embed error:
Error() from the embedded interface. But it does not automatically gain Unwrap(). To make errors.Is and errors.As walk through: Without this method, the embedded error is hidden from the unwrap chain. Q28. What is the difference between errors.Is(err, target) and err.(*MyErr)?¶
errors.Is(err, target)walks the wrap chain, checking each layer with==or a customIsmethod. It looks for equivalence.err.(*MyErr)is a type assertion on the outer error only. It does not walk the chain. It checks for exact dynamic type.
Use errors.As (not type assertion) if you want chain-walking type-based matching. Use type assertion only when you know the immediate concrete type and do not care about wrapping.
Q29. What invariants should Error() string preserve?¶
- Cheap and predictable. No I/O, no expensive allocation. It is called by loggers and
fmt, possibly under contention. - Idempotent. Calling it twice should give the same result.
- Safe on nil receivers if you intend to allow nil pointers (rare; usually avoid).
- Free of secrets. Error messages are often logged or returned to users.
Q30. What is the behavioral interface pattern, and where has the standard library moved away from it?¶
The pattern: define an interface like interface { Temporary() bool } and have errors that "are temporary" implement it. The net package historically used this for retry decisions.
Move-away: behavioral methods like Temporary() were deprecated in net (Go 1.18+) because the predicate was poorly defined. Modern Go favors specific sentinels (net.ErrClosed, context.Canceled) and errors.Is checks.
The pattern is still useful for your own errors when you need capability dispatch — it just requires precise contract definitions.
Professional¶
Q31. What is the memory layout of an error interface value?¶
Two machine words (16 B on 64-bit): *itab (interface-table pointer) and unsafe.Pointer (data pointer). The itab encodes both the dynamic type and the method dispatch table. A nil error is (nil, nil).
Q32. What is an itab, and how is it constructed?¶
An itab is a runtime structure: {interface_type, concrete_type, hash, method_pointers...}. The runtime maintains a global itab cache keyed by (interface, concrete_type). The first time a value of a given concrete type is converted to a given interface, the runtime constructs and caches the itab. Subsequent conversions hit the cache.
For error, the itab has one method slot pointing to the concrete Error implementation.
Q33. How does method dispatch through an interface compile?¶
Compiles to roughly: 1. Loaderr.itab from the interface header. 2. Load the function pointer at itab.fun[0] (the Error slot). 3. Pass err.data as the receiver. 4. Indirect call. Two pointer indirections. ~2-5 ns on warm cache; ~20-30 ns cold. The compiler cannot inline this because it does not know which Error is being called.
Q34. How can you cause devirtualization?¶
If the compiler can prove the concrete dynamic type at the call site, it replaces the interface call with a direct call:
But: Recent Go versions (1.21+) improve devirtualization in some cases. You cannot rely on it across compilers; if you need a direct call, type the variable concretely.Q35. Why does converting a value type to an interface sometimes allocate?¶
The interface's data word is one machine word. A value type wider than one word must be boxed onto the heap so its address can fit in the data word. Even if the value fits in one word, the compiler may box it depending on escape analysis.
Pointer types do not box — the pointer itself fits. So var e error = &MyErr{} does not allocate at the conversion (only at the &MyErr{} allocation, if it escapes); but var e error = MyErr{} may allocate to box.
Q36. What does errors.Is do internally, line by line?¶
func Is(err, target error) bool {
if target == nil { return err == target }
isComparable := reflectlite.TypeOf(target).Comparable()
for {
if isComparable && err == target { return true }
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
switch x := err.(type) {
case interface{ Unwrap() error }:
err = x.Unwrap()
if err == nil { return false }
case interface{ Unwrap() []error }:
for _, e := range x.Unwrap() {
if Is(e, target) { return true }
}
return false
default:
return false
}
}
}
== (or the layer's Is), then unwraps. Cost: ~few ns per layer. Q37. How does errors.As differ from errors.Is internally?¶
errors.As uses reflectlite.TypeOf to check assignability between each layer's dynamic type and the target's element type. If assignable, it uses reflect to write into the target. If not, it tries a custom As method, then unwraps.
The reflection makes As slower than Is (~50 ns vs ~10 ns for shallow chains). For repeated checks, prefer caching via type assertion if the type is fixed.
Q38. What happens if your Error() method panics?¶
The panic propagates up the stack like any other panic. fmt.Println(err) would crash unless a deferred recover is in place. Loggers sometimes wrap their formatting in recover to avoid this — but you should never rely on it.
Keep Error() defensive: handle nil fields, never call into untrusted code, never do I/O.
Q39. How does errors.Join represent a multi-error in memory?¶
Source ($GOROOT/src/errors/join.go):
*joinError allocated on the heap, containing a slice of the joined errors. Its Error() formats each joined error joined by \n. Its Unwrap() []error returns the slice. errors.Is and errors.As recognize the multi-unwrap form and recurse into each element.
Q40. What is the cost of a wrap chain at depth N?¶
errors.Is: O(N) comparisons. Each is one type assertion + one==. ~3-5 ns each.errors.As: O(N) reflect-based assignability checks. ~20-50 ns each.- Memory: each layer is one heap object. GC marks each.
- Allocation at construction: each
fmt.Errorf("...%w", ...)allocates a*wrapError(~32 B + the formatted message backing).
For typical chains (depth 2-5) this is invisible. For depth 100+ in a hot path you have a real problem.
Q41. Can two different error types compare equal under ==?¶
No. Interface equality requires identical dynamic types. Two errors of different concrete types compare not-equal under ==, even if their Error() strings are identical. Same string, different types: distinct values.
This is why errors.Is with a custom Is method is the right tool for value-equality semantics.
Q42. What is the cost of constructing errors.New("foo") at the package level vs inside a function?¶
- Package level:
var ErrFoo = errors.New("foo")runs once at init. The allocation is one-time; the value lives in the data segment and never gets GC'd. - Inside a function:
return errors.New("foo")allocates per call. The*errorStringescapes (it is returned), so it goes on the heap. Each call: one allocation, one GC mark.
For sentinels, package-level is the right choice. For dynamic messages, fmt.Errorf is unavoidable.
Behavioral / Code Review¶
Q43. You see a code review where someone defined Error() on a value receiver but every caller does &MyErr{}. Comment?¶
The pointer is gratuitous: MyErr{} would also satisfy error since the method is on the value. Suggesting MyErr{} directly avoids the & everywhere. Conversely, if pointer identity matters (for errors.Is comparisons), the value receiver is wrong — switch to pointer receiver.
Q44. A junior shows you this code. What's the issue?¶
RecursiveError(). %v calls Error() on a value that has one — infinite recursion, stack overflow. Fix: format the struct's fields, not the struct itself: fmt.Sprintf("err: %s", e.Msg). Q45. Walk me through reviewing a custom error type for a public package.¶
- Is the type comparable? (No slices, maps, funcs as fields.)
- Are exported fields stable? (Documented, never to be removed.)
- Is there a constructor? (Avoids reliance on struct literals.)
- Does
Error()return a sensible single-line message? - Is
Unwrap()defined if there is a wrapped cause? - Is
Is(target error) booldefined for value-equality semantics? - Are sentinel errors documented?
- Do behavioral methods (StatusCode, Temporary, ...) have clear contracts?
- Is the type used anywhere internally? (Defining and never returning is dead code.)
Q46. Senior reviewer asks: "Why not just use errors.New and skip custom types entirely?"¶
Custom types let callers extract structured data via errors.As. Without them, callers reduce to string matching, which is brittle. Custom types also enable behavioral interfaces — interface { StatusCode() int } lets handlers pick HTTP codes without coupling to a specific error type.
Use errors.New for sentinels and trivial errors; use custom types when callers need to inspect structured data.
Q47. A team wants to add a Stack []uintptr field to every error type. Pros and cons?¶
Pros: debugging is faster — every error has a stack. Cons: - Capture cost (~µs per error). - Allocation: every error pulls a slice plus PCs. - Discipline: stacks must be captured at the original failure point, not at every wrap. - Storage: stacks bloat logs.
For a high-volume service, the cost is real. Better: capture stacks selectively (a "diagnostic" error type) and let plain errors stay light.