Skip to content

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.

OutcomeStatusBody
A request body that is not JSON400{ error: "Bad Request" }
An invalid changeset (result.success is false)422{ errors: result.error.errorMessages() }
NoResultsError from a ...OrThrow read404{ error: "Not Found" }
StaleEntryError: the row changed or was deleted since it was read409{ error: "Conflict" }
ConstraintError with type: "unique", for a constraint the changeset did not declare409{ error: "Conflict" }
Anything else500error.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 as JSON.stringify writes it: createdAt is a string, and the virtual password is not there (nor would a redacted field be).
  • ErrorMessages<ReturnType<typeof User.changeset>> is the type of errorMessages() on the changeset User.changeset makes, and on the one the write gives back: one optional key per field and association, $base, and the keys its validations add (passwordConfirmation). JsonReturn of 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 with has already been taken on 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, its code and its id, which you can look up in Errors.
  • Send rows, not copies of them: Response.json(user) writes what Json<User> says, but a copy ({ ...user }, const { email, ...rest } = user) is a plain object, which JSON.stringify writes in full, redacted and virtual fields included. Json of a copy says so; Json of a mapped type such as Omit<User, "email"> still describes the row.

See also ​