A Good Error Object Is a UX Pattern
The error your API throws determines what the UI can show. A flat string is a design decision — and usually the wrong one.
The error shape you return from an API endpoint is a design decision. If you return { message: "Something went wrong" }, you have decided that the frontend can only show a generic toast. If you return { code: "CARD_DECLINED", field: "paymentMethod", retryable: true, message: "Your card was declined" }, you've opened up field-level validation hints, retry logic, and context-aware recovery flows. The difference isn't backend complexity — it's intent. Most APIs ship flat strings by accident, not by choice.
What a flat error string forces on the UI
When an endpoint returns a plain message string, the React layer has two options: display it verbatim or hide it entirely. Neither is good. Display it verbatim and you're showing backend language to a user who doesn't know what a foreign key constraint is. Hide it and you're showing a spinner that never resolves.
The real cost is that the frontend can't route the error. It can't decide whether this should be a field-level red border, a banner at the top of the form, a full-page error state, or a toast that auto-dismisses. All of those are different states. A string can't carry that information.
The pattern I've reached for on every API-heavy project — Tranzport's shipment booking flow, Waco3's admin panel — is a structured error object with five fields that each serve a different consumer.
The five fields that matter
interface AppError {
code: string; // Machine-readable. Drives switch statements, not string matching.
message: string; // Human-readable. Can be shown in a UI if needed.
field?: string; // Present when the error is scoped to one input.
retryable: boolean; // Can the client try again without user action?
statusCode: number; // HTTP status. Drives how the response layer handles it.
}
code is the most important field. It's what your React error handler switches on. INVALID_DATE_RANGE, SHIPMENT_ALREADY_BOOKED, RATE_LIMIT_EXCEEDED — these are tokens your frontend can respond to programmatically. No string matching on message. No if (error.message.includes("already")) that breaks the moment someone rewrites the copy.
field scopes the error. When a form submission fails because originZip doesn't match a valid service area, you need field: "originZip" to know where to place the red border and helper text. Without it, you show the error at the form level and the user hunts for what they did wrong.
retryable drives loading state and button state. A network timeout is retryable; an invalid email format is not. The button label changes: "Try again" vs "Fix the errors above." Your error boundary component needs this bit to decide whether to offer a retry action.
statusCode is for the HTTP response layer, not the UI. A 422 Unprocessable Entity means the client sent bad data — don't retry. A 503 Service Unavailable means something is down — do retry with backoff. Your fetch wrapper can use this to auto-classify errors before they reach component logic.
A custom AppError class in Node.js
export class AppError extends Error {
code: string;
field?: string;
retryable: boolean;
statusCode: number;
constructor({
code,
message,
field,
retryable = false,
statusCode = 400,
}: {
code: string;
message: string;
field?: string;
retryable?: boolean;
statusCode?: number;
}) {
super(message);
this.name = "AppError";
this.code = code;
this.field = field;
this.retryable = retryable;
this.statusCode = statusCode;
}
}
// Usage in a route handler
export function validateShipmentDates(pickup: Date, delivery: Date) {
if (delivery <= pickup) {
throw new AppError({
code: "INVALID_DATE_RANGE",
message: "Delivery date must be after pickup date.",
field: "deliveryDate",
retryable: false,
statusCode: 422,
});
}
}
The Express (or Fastify, or Hono) error middleware catches AppError instances and serializes them consistently:
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
code: err.code,
message: err.message,
field: err.field ?? null,
retryable: err.retryable,
});
}
// Unhandled errors become a generic 500
console.error(err);
return res.status(500).json({
code: "INTERNAL_ERROR",
message: "An unexpected error occurred.",
field: null,
retryable: true,
});
});
How this maps to React UI states
On the frontend, you now have enough information to route errors to the right component:
type ApiError = {
code: string;
message: string;
field: string | null;
retryable: boolean;
};
function useFormError(error: ApiError | null) {
if (!error) return { fieldError: null, formError: null };
if (error.field) {
// Render under the specific input
return { fieldError: { field: error.field, message: error.message }, formError: null };
}
if (error.code === "RATE_LIMIT_EXCEEDED") {
// Banner — not the user's fault, not dismissible until the period resets
return { fieldError: null, formError: { type: "banner", message: error.message } };
}
// Default: toast
return { fieldError: null, formError: { type: "toast", message: error.message } };
}
Three different display modes. No string parsing. No fragile includes() checks. The code field does the routing; the field field does the placement; retryable drives the button state. This is why error shape is a design decision.
Discriminated unions as an alternative
If you prefer TypeScript discriminated unions over a class hierarchy:
type FieldError = {
type: "field";
code: string;
message: string;
field: string;
retryable: false;
statusCode: 422;
};
type ServiceError = {
type: "service";
code: string;
message: string;
retryable: boolean;
statusCode: 400 | 429 | 500 | 503;
};
type AppError = FieldError | ServiceError;
The type discriminant lets TypeScript narrow for you in switch statements. The tradeoff: you need to be more deliberate about which variant you're constructing at throw-time. The class approach is easier to throw from deep in a call stack; the union approach is easier to exhaustively handle on the frontend.
What this prevents
On the Tranzport shipment flow, we had an early endpoint that returned { error: "Invalid pickup location" } for three different failure modes: an unserviceable zip code, a missing warehouse configuration, and a carrier availability gap. The UI showed the same generic string for all three. Fixing a carrier gap required a support call; fixing a zip code was a one-field edit. Same error message, completely different recovery path.
Structured errors with distinct codes — UNSERVICEABLE_ZIP, WAREHOUSE_NOT_CONFIGURED, NO_CARRIER_AVAILABILITY — let the frontend route to the right recovery: inline field hint, admin notification, or a "try a different date" suggestion. The backend work was adding a field and two constants. The UX difference was significant.
The principle: every time you write throw new Error("something failed"), you are deciding that the user will see nothing useful. Error design is interface design. Treat it that way from the first endpoint.