AlgoMaster Logo

Custom Error Types

Last Updated: May 22, 2026

Medium Priority
10 min read

A custom error type is any type whose pointer or value satisfies Go's built-in error interface by having an Error() string method. Custom types let callers extract structured information from an error (which field failed, which resource was missing, which retry count was reached) instead of parsing a string. This chapter covers how to define a custom error type, what to put in its fields, the pointer receiver convention, the typed-nil trap that bites people returning custom errors, and how to add Unwrap so the chain plays nicely with the errors package.

Satisfying the error Interface

The error interface is the simplest interface in the standard library. It has exactly one method:

Any type with an Error() string method satisfies it. There's no implements keyword and no annotation. The compiler checks the method set when you assign a value to an error variable or return one from a function.

A custom error type is just a struct with an Error() method:

ValidationError is a regular struct with two fields. The pointer receiver method (e *ValidationError) Error() string makes *ValidationError satisfy the error interface. The function checkEmail returns &ValidationError{...}, which the caller stores in an error variable and prints with the standard fmt.Println.

The reason to do this instead of errors.New("validation failed on email: must not be empty") is the fields. A caller that knows the concrete type can pull Field out and decide what to do based on which field failed, without parsing the message. Custom types are for cases where a string isn't enough.

The diagram shows what happens when you define a custom error type. The struct holds context fields. The method Error() string on the pointer receiver makes *ValidationError satisfy the error interface. From there, the type can be returned from any function whose return type is error, and callers see it through the interface.

Fields That Carry Context

The whole point of a custom error type is the fields. A plain string error tells the caller "something went wrong"; a custom error tells the caller exactly what went wrong, with enough detail to decide what to do next.

Here's a pair of types that an e-commerce backend might define for two different failure modes:

The caller uses errors.As to test which concrete type came back and then reads the relevant fields. ValidationError carries Field and Reason. NotFoundError carries Resource and ID. Each type owns the data its callers will need.

The fields should be the bits of information a caller has a reason to look at programmatically. A ValidationError that also stores the file name, the line number, and the time of day is doing too much. The field set is a tiny API. If callers don't read a field, it shouldn't be there.

A useful rule when deciding what to include: if a caller could write if err.Field == "email" { suggestSignup() }, include the field. If the field would only ever appear inside Error() for the human-readable message, leave it out and just hardcode the message text.

Pointer Receivers vs Value Receivers

The convention is to define Error() on a pointer receiver and return &MyError{...} from functions. There are two reasons for this, and both matter.

The first is consistency with how the interface is checked. When errors.As(err, &target) walks the chain, it tries to assign the concrete error value to target. If target is **ValidationError and the chain contains a *ValidationError, the assignment works. If you defined Error() on the value receiver and the chain holds a ValidationError value, callers have to write var v ValidationError and errors.As(err, &v), which works but mixes pointer-and-value conventions across a codebase that mostly uses pointer errors.

The second is mutation and equality. Pointer-equal errors compare by identity, which is exactly the behavior you want when you store an error in a package-level variable. Two *ValidationError values created by separate calls are different pointers, even when their fields match, so errors.Is based on pointer identity gives sensible results.

Here's the same type defined both ways, side by side:

Both compile, and both implement error. The pointer version is what the standard library uses (*os.PathError, *net.OpError, *json.SyntaxError, and so on). The value version shows up occasionally for types that are immutable and small, where copying the value is cheaper than the indirection of a pointer.

The trade-off:

ReceiverAllocationMutationerrors.As patternTypical use
Pointer (*T)Heap (escapes via interface)Allowedvar t *MyErrAlmost all custom errors
Value (T)Stack-friendly when not boxedEach copy is independentvar t MyErrTiny, immutable error types

Once a value satisfies an interface, Go usually moves it to the heap anyway (it escapes), so the "stack-friendly" advantage of value receivers often doesn't actually apply for errors. Most Go developers default to pointer receivers and only switch to value receivers when there's a specific reason.

The Typed-Nil Return Trap

This is the most common bug with custom error types. The version that shows up in error handling looks like this:

The function meant to return "no error" when the email is non-empty. It declared var e *ValidationError, left e as nil, and returned it. The caller's err != nil check is true, even though the underlying pointer is nil. The program prints "got error: <nil>" instead of "ok".

The reason is interface internals. An interface value is two words: a type descriptor and a value pointer. err is nil only when both words are nil. Returning the typed nil variable e fills in the type word with *main.ValidationError and leaves the value word nil. That interface is not equal to nil because the type word is set.

The diagram shows the two-word layout for an interface holding a typed nil. Even when the value word is nil, the type word makes the interface compare not-equal to nil at the caller. The fix is to return the untyped nil literal whenever there's no error, so both words stay empty.

The fix has two common shapes. The first is to return nil directly instead of returning a typed pointer variable:

Now the function returns the untyped nil literal on the success path. The caller's err != nil check is false, and the program prints "ok".

The second fix is to keep the variable but check for nil before returning:

The explicit if e == nil { return nil } short-circuits the typed nil. This shape is occasionally useful when the function builds the error up over several conditions and you don't want to restate the construction in each branch. Most of the time the first form (return nil directly) is cleaner.

A simple rule that avoids the trap entirely: a function whose return type is error should never have a local variable typed *MyError that it returns directly. Either return &MyError{...} inline, or assign to a variable of interface type error, or check for nil before returning.

Adding Unwrap for errors.Is and errors.As

A custom error type can participate in the wrap chain by adding an Unwrap() error method. The method returns the next error down, or nil if there isn't one.

Here's a NotFoundError that also remembers what caused it:

NotFoundError has its own fields (Resource, ID) and also stores a Cause. The Unwrap method returns e.Cause, which lets errors.Is(err, ErrDBClosed) walk the chain and match the sentinel. At the same time, errors.As(err, &nf) finds the *NotFoundError and assigns it to nf, so the caller can read the resource fields.

The thing your custom type needs to provide is the Unwrap method whose return value is the next error in the chain.

The diagram shows the chain a single-step Unwrap creates. The outer *NotFoundError exposes its cause through Unwrap(). When code calls errors.Is(err, ErrDBClosed), the walker compares the outer error, then follows Unwrap, then compares the inner error, and so on until either a match is found or Unwrap returns nil.

A few rules for writing Unwrap:

  • The signature must be exactly Unwrap() error. A different name or a different return type won't be picked up by errors.Is and errors.As.
  • Return nil when there's nothing to unwrap. Returning a typed nil pointer field hits the same gotcha as before; if the field is a pointer to another custom error, check it first or assign it to an interface variable.
  • The receiver should match the receiver of Error(). If Error() is on *T, Unwrap() should also be on *T.
  • Don't unwrap to yourself or build a cycle. The walker has no cycle protection beyond a finite step limit. A self-referential Unwrap is a hang waiting to happen.

There's also an Unwrap() []error variant added in Go 1.20 for errors that wrap multiple causes (used by errors.Join). It's the same idea with a slice return.

When to Choose a Custom Type Over fmt.Errorf

Most error returns don't need a custom type. A formatted string error from fmt.Errorf is enough when:

  • The caller will log or display the error and move on.
  • No code along the chain has to make a programmatic decision based on the failure mode.
  • The error is unique to one call site and won't be matched anywhere else.

A custom type starts to pay for itself when callers need to extract structured fields, group failures by category, or change behavior based on the error. Some examples:

Situationfmt.Errorf is fineCustom type
Logging a failed readYesNo
Returning "user not found" for an API to convert to HTTP 404NoYes, callers need to detect it
Bubbling up an OS error with extra contextYes (use %w)Only if downstream needs typed fields
Validation errors with per-field reasonsNoYes, the UI needs Field and Reason
One-off "feature flag disabled"YesNo
Retryable failures with a wait timeNoYes, the retry loop needs RetryAfter

The shape of the decision is: does any caller need to read structured data from this error? If yes, custom type. If no, fmt.Errorf with %w is enough.

A small example that compares the two. With a string error:

The string comparison works, but it's brittle. Any reword of the message breaks the caller. Compare with a custom type:

The caller now has access to the actual numbers without parsing. If the rate-limit ever changes, the structure of the check stays the same.

Naming and Stutter Avoidance

The naming of custom error types matters because they're an API surface. The conventions Go developers follow are mostly about avoiding stutter and matching the standard library's style.

The standard library names error types with the suffix Error. Examples include *os.PathError, *json.SyntaxError, *net.OpError, *strconv.NumError. The pattern is <Cause>Error, where <Cause> describes what went wrong.

When the type lives in a package whose name already includes "error" or whose role is errors, the suffix becomes redundant. If a package called validate defines a single error type, it's idiomatic to name it just Error, so callers write validate.Error, not validate.ValidationError:

A caller imports it and writes var e *validate.Error, which reads naturally. Writing var e *validate.ValidationError would repeat the package name in the type and is what Go developers mean by "stutter".

If a package has multiple distinct error types, you can either give each one a descriptive name (no suffix) or keep the Error suffix and accept a small amount of stutter. The Error suffix wins when the types could be confused with non-error structs:

Callers write payment.DeclinedError and payment.TimeoutError. The suffix makes the type's role clear at the call site. The alternative (payment.Declined, payment.Timeout) is also valid Go style, especially when the package's role is obviously error handling.

A few rules of thumb for naming:

  • Exported (caller-visible) error types start with an uppercase letter. Unexported "internal" error types start with lowercase and are only useful inside the package.
  • Use the Error suffix unless the package name already conveys that the type is an error.
  • The type name should describe the failure, not the action. NotFoundError is better than LookupError, because a caller cares about "what happened", not "what was tried".
  • Don't pluralize. ValidationError for a single failure, ValidationErrors only if the type actually carries a slice of failures.

The following package layout shows multiple types organized cleanly:

Three distinct error types live happily in the same package. Each has a clear name describing its failure mode, fields that match what callers need, and a pointer-receiver Error() method. The caller uses errors.As to pick which type they received and reads only the fields they care about.

Putting It Together: A Custom Type with Unwrap

Here's an end-to-end example that uses everything covered so far. A small order-processing pipeline returns a custom OrderError that wraps an inner cause. The caller uses errors.Is to test for sentinel causes and errors.As to extract the order ID.

What this example demonstrates:

  • OrderError is a custom type with three fields. OrderID and Step are caller-visible context; Cause holds the inner error.
  • The pointer-receiver Error() formats a human message that includes the cause.
  • The pointer-receiver Unwrap() returns Cause, so errors.Is(err, ErrOutOfStock) and errors.Is(err, ErrPaymentDeclined) walk the chain and match the sentinel.
  • errors.As(err, &oe) extracts the typed *OrderError so the caller can read OrderID and Step directly.
  • The success path returns nil from placeOrder, never a typed nil.

That last point is the small habit that prevents the typed-nil trap. Notice that reserveStock and chargePayment return &OrderError{...} on the failure path and nil on the success path. There's no local variable typed *OrderError getting returned across the function boundary, so the caller's err == nil check always works the way you'd expect.