Errors as HTTP responses
Egon ships no HTTP helper: which status an error deserves is your application's decision. This recipe maps the expected outcomes of a read or a write to responses in one function, and types the response body on the client.
| Outcome | Status | Body |
|---|---|---|
| A request body that is not JSON | 400 | { error: "Bad Request" } |
An invalid changeset (result.success is false) | 422 | { errors: result.error.errorMessages() } |
NoResultsError from a ...OrThrow read | 404 | { error: "Not Found" } |
StaleEntryError: the row changed or was deleted since it was read | 409 | { error: "Conflict" } |
ConstraintError with type: "unique", for a constraint the changeset did not declare | 409 | { error: "Conflict" } |
| Anything else | 500 | error.toJSON() for an error of the library, { name, code, id }; { error: "Internal Server Error" } otherwise. The message goes to your logs. |
Setup
ts
import { postgres } from "@egon-orm/postgres";
import {
ConstraintError,
cast,
createRepo,
type ErrorMessages,
isEgonError,
type Json,
NoResultsError,
Schema,
StaleEntryError,
t,
timestamps,
} from "egon-orm";
const repo = createRepo({ adapter: postgres({ connectionString: process.env.DATABASE_URL }) });
class User extends Schema("users", {
id: t.integer().primaryKey().dbGenerated(),
email: t.string(),
password: t.string().virtual(), // a real app would also hash it into a redacted field
lockVersion: t.integer().default(1),
...timestamps(),
}) {
static changeset(user: User | typeof User, params: unknown) {
return cast(user, params, ["email", "password"])
.validateRequired(["email", "password"])
.validateConfirmation("password")
.uniqueConstraint("email")
.optimisticLock("lockVersion");
}
}One function for the errors
ts
/** The response for an error a handler did not handle itself. */
export function errorResponse(error: unknown): Response {
if (error instanceof NoResultsError) {
return Response.json({ error: "Not Found" }, { status: 404 });
}
if (error instanceof StaleEntryError) {
// Someone else changed or deleted the row since it was read.
return Response.json({ error: "Conflict" }, { status: 409 });
}
if (error instanceof ConstraintError && error.type === "unique") {
// A unique constraint the changeset did not declare: declare it to get a 422 instead.
return Response.json({ error: "Conflict" }, { status: 409 });
}
console.error(error); // the full message, for your logs
// An error of the library is written as { name, code, id }: no message, SQL or values.
const body = isEgonError(error) ? error.toJSON() : { error: "Internal Server Error" };
return Response.json(body, { status: 500 });
}The handlers
ts
/** POST /users: 201 with the user, 400 for a body that is not JSON, 422 for an invalid changeset. */
export async function signUp(request: Request): Promise<Response> {
let params: unknown;
try {
params = await request.json();
} catch {
return Response.json({ error: "Bad Request" }, { status: 400 });
}
try {
const result = await repo.insert(User.changeset(User, params));
if (!result.success) {
return Response.json({ errors: result.error.errorMessages() }, { status: 422 });
}
return Response.json({ user: result.data }, { status: 201 }); // without the password
} catch (error) {
return errorResponse(error);
}
}
/** GET /users/:id: 200 with the user, or 404. */
export async function showUser(id: number): Promise<Response> {
try {
return Response.json({ user: await repo.getOrThrow(User, id) });
} catch (error) {
return errorResponse(error);
}
}A body that cannot be parsed is the client's mistake, answered with 400 before any database work. A write returns a result, so the invalid changeset is handled where it happens, with its messages. The exceptions are the outcomes a handler does not expect, and go to errorResponse.
On the client
ts
// What the client receives, typed from the schema: no class, no Date, no password.
export type SignUpResponse =
| { readonly user: Json<User> } // 201
| { readonly errors: ErrorMessages<ReturnType<typeof User.changeset>> }; // 422
export async function submit(form: FormData): Promise<SignUpResponse> {
const response = await fetch("/users", { method: "POST", body: JSON.stringify(Object.fromEntries(form)) });
return (await response.json()) as SignUpResponse;
}
export function firstError(response: SignUpResponse): string | undefined {
if (!("errors" in response)) return undefined;
// Typed by field: a misspelt key does not compile. passwordConfirmation is there because
// User.changeset calls validateConfirmation.
return response.errors.email?.[0] ?? response.errors.passwordConfirmation?.[0];
}Json<User>is the user asJSON.stringifywrites it:createdAtis a string, and the virtualpasswordis not there (nor would a redacted field be).ErrorMessages<ReturnType<typeof User.changeset>>is the type oferrorMessages()on the changesetUser.changesetmakes, and on the one the write gives back: one optional key per field and association,$base, and the keys its validations add (passwordConfirmation).JsonReturnof a handler that returns it is the same type.
Good to know
- Declare the constraints a user can violate (
uniqueConstraint("email")): the write then returns an invalid changeset withhas already been takenon the field, which belongs in a 422, instead of throwing. - A 500 response carries no message. An error's message can name tables and constraints, which is for your logs, not your users;
error.toJSON()writes only its class name, itscodeand itsid, which you can look up in Errors. - Send rows, not copies of them:
Response.json(user)writes whatJson<User>says, but a copy ({ ...user },const { email, ...rest } = user) is a plain object, whichJSON.stringifywrites in full, redacted and virtual fields included.Jsonof a copy says so;Jsonof a mapped type such asOmit<User, "email">still describes the row.
See also
- Showing form errors (React)
- Errors in the changesets guide
- Frameworks

