How do you define a custom Go error type that carries structured fields a caller can read?
answer
- errors here are values, not exceptions
- one method is the whole contract
- put the facts in a struct
- Error() string, usually pointer receiver
- exported fields are the caller's API
basics
~20 sDeclare a struct holding the data and give it an Error() string method, normally on a pointer receiver. Any type with that method is usable as an error, and callers read the exported fields off the concrete value.
solid answer
~40 sA typed error is an ordinary struct that also has an `Error() string` method. Put the machine-readable facts in exported fields, for example `Field`, `Rule` and `Value` on a `FieldError` produced by a schema validator, and let `Error()` format them into one human-readable line. Declare the method on a pointer receiver (`func (e *FieldError) Error() string`) and always return `&FieldError{...}`, so there is exactly one dynamic type callers have to target. The point of the struct is that the caller no longer has to parse a message: it recovers the concrete type with `errors.As` and reads `fe.Field` directly. Keep the message lowercase, without trailing punctuation, so it reads well when another layer prefixes its own context.
code
go · 17 lines// Code generated from the User schema. DO NOT EDIT.
type FieldError struct {
Field string `json:"field"`
Rule string `json:"rule"`
Value any `json:"value"`
}
func (e *FieldError) Error() string {
return fmt.Sprintf("field %s violates rule %s", e.Field, e.Rule)
}
func validateEmail(v string) error {
if !strings.Contains(v, "@") {
return &FieldError{Field: "email", Rule: "format", Value: v}
}
return nil
}go deeper
Be ready to write the type on a whiteboard from memory: a struct with exported fields plus func (e *T) Error() string, and a return of &T{...}. Know that nothing else is required to make it an error.
Explain why the method usually goes on the pointer receiver and what changes in the method set if it does not, and show how the caller recovers the fields with errors.As rather than a bare type assertion.
Treat the exported type and its field names as a public API contract other services will branch on, and be able to say what breaks downstream when you rename a field or stop returning the type on a path.
Frame the choice as a boundary decision: what structured failure data your packages promise across teams, whether that data is stable enough to expose as a type, and what it costs to change it later.
## What a typed error is In Go, `error` is an ordinary interface with a single method: ```go type error interface { Error() string } ``` A *typed* error (also called a structured or custom error) is a named type you declare yourself that satisfies that interface **and carries fields**. The message string is still there for humans, but the fields are there for code: the caller can look at `e.Field`, `e.Line` or `e.StatusCode` and branch on it instead of doing string surgery on the message. ## The declaration Three things are needed, and only three: 1. A type — almost always a struct, because you want more than one fact in it. 2. Exported fields for whatever the caller is meant to use. 3. An `Error() string` method that renders those fields into one line. ```go type FieldError struct { Field string Rule string } func (e *FieldError) Error() string { return "field " + e.Field + " violates rule " + e.Rule } ``` That is the whole contract. There is no base class to embed, nothing to register, and no interface list to declare — Go's interface satisfaction is structural, so writing the method is what makes the type an error. ## Value or pointer receiver The overwhelmingly common convention is a **pointer receiver** and a pointer at the return site: ```go return &FieldError{Field: "email", Rule: "format"} ``` With `func (e *FieldError) Error() string`, only `*FieldError` satisfies `error`; the bare `FieldError` value does not. That is a feature: there is exactly one dynamic type in play, so every caller knows what to match against, and nothing accidentally copies a large struct on every call to `Error()`. If you write the method on a value receiver instead, *both* `FieldError` and `*FieldError` satisfy `error`, and callers now have to guess which one your package actually returns. The compiler catches the mismatch immediately if you get it backwards. Returning `FieldError{...}` where the method is on `*FieldError` fails to build with a message saying the type does not implement `error` because method `Error` has a pointer receiver. ## What the message should say Go's convention is that error strings are lowercase and carry no trailing punctuation, because they are routinely concatenated: an outer layer writes `"loading user profile: "` in front of yours and the result should read as one sentence. Include the field values in the text — the message and the fields should tell the same story, so the log line is useful even where nobody inspected the type. ## How the caller uses it The caller does not type-assert blindly. It declares a variable of the pointer type and hands its address to `errors.As`, which searches the error and everything it wraps and assigns the first match: ```go var fe *FieldError if errors.As(err, &fe) { httpStatus = 422 badField = fe.Field } ``` That is why the fields are exported and why the type itself is usually exported: once a caller writes `errors.As` against it, the type name and its field names are part of your package's public API, and renaming `Field` is a breaking change exactly like renaming a function. ## Where this shape earns its keep Generated code is the clearest case. A code generator that emits Go types from a schema can emit one `FieldError` type alongside them, populated with the schema path and the rule that failed. The consumer of the generated package then gets machine-readable validation failures for free — it can build an HTTP 422 body listing every bad field — without the generator having to invent a message format and without the consumer having to parse one back. ## Common mistakes * Declaring `String() string` instead of `Error() string`. That satisfies `fmt.Stringer`, not `error`, and the compiler will reject the return. * Putting the interesting data only in the message text, so the caller has to parse it back out. If you had to write the value into a string, it should have been a field. * Hiding the fields behind unexported names with no accessors, which leaves the caller with nothing but the message again. * Mixing receivers within one type, so some methods are on `T` and `Error()` is on `*T` — legal, but it makes the method set harder to reason about for no benefit.
- Why is a pointer receiver the usual choice for Error() on a custom error type?Because it leaves exactly one dynamic type in play. With `func (e *FieldError) Error() string`, only `*FieldError` satisfies `error`, so every caller targets the same type and nobody has to wonder whether your package returns a value or a pointer. It also avoids copying the struct on every call to `Error()`.
- Should the struct's fields be exported?Yes, if the point of the type is for callers to read them — an unexported field with no accessor leaves the caller back at parsing the message. Exporting them makes the field names part of your public API, so name them as carefully as function names and change them only as a breaking change.
- How should the string returned by Error() be worded?Lowercase, no trailing punctuation, and containing the same facts the fields carry. Error strings get concatenated by outer layers that prefix their own context, so `"field email violates rule format"` reads correctly after `"validating request: "`, whereas a capitalised sentence ending in a period does not.
saying these in an interview costs you the question
- Declares String() string and expects it to satisfy error
- Thinks a custom error must embed or subclass something
- Puts the data only in the message text for the caller to parse
- Returns the struct by value while Error() has a pointer receiver
- Believes the type has to be registered with the runtime