#Typed errors
Every error in Alepha is declared with $error: a name, and a schema for the data it carries. A $action can declare the errors it answers with. Declared errors are part of its contract: they reach the client with their status and typed data, they appear in the OpenAPI document, and the browser rebuilds them so instanceof works there too. Anything else the handler throws is a bug, and answers a 500 like any other crash.
This is opt-in, per action. An action without errors behaves as described in Error Handling.
#Declaring an error
$error returns a class, declared once at module level like $atom. It is not tied to HTTP: the CLI, the ORM and the job runner declare theirs the same way.
1import { $error, z } from "alepha"; 2 3export const QuotaExceeded = $error({ 4 name: "QUOTA_EXCEEDED", 5 schema: z.object({ limit: z.number(), used: z.number() }), 6 default: { limit: 5 }, 7 message: (data) => `Quota of ${data.limit} exceeded`, 8}); 9 10// Optional: a name for function signatures.11export type QuotaExceeded = InstanceType<typeof QuotaExceeded>;12 13throw new QuotaExceeded({ used: 6 }); // `limit` has a default
nameis required, SCREAMING_SNAKE, and unique. It is the error'sError.nameand its identity on the wire. Nothing enforces uniqueness: a second declaration of a name replaces the first in the registry (so a dev server re-evaluating a module never throws), and the client then rebuilds the later class.schematypesdata.defaultmakes the keys it covers optional when constructing. The constructor does not validate: the schema is checked when the error is rebuilt from JSON (see Branching in React).messageis the default message: a string, or a function of the data. Without one, the parent's, else the name.descriptionis prose for OpenAPI and the docs.- The constructor is
(data, { message?, cause? }). When every data key is optional,new X("message", { cause })works as it does forError.
#Deriving errors
.extend derives a real subclass: instanceof the parent holds, its schema is merged into the parent's and its defaults over the parent's. An HTTP error is an error derived from HttpError, whose schema carries the status:
1import { z } from "alepha"; 2import { HttpError, NotFoundError } from "alepha/server"; 3 4export const ProjectQuotaExceeded = HttpError.extend({ 5 name: "PROJECT_QUOTA_EXCEEDED", 6 default: { status: 409 }, 7 schema: z.object({ limit: z.number(), used: z.number() }), 8}); 9 10export const ProjectNotFound = NotFoundError.extend({11 name: "PROJECT_NOT_FOUND",12 message: "No such project",13});14 15const error = new ProjectQuotaExceeded({ limit: 5, used: 6 });16error.status; // 409, a shortcut for error.data.status17error instanceof HttpError; // true
Any layer can throw them; a service needs no reference to the action that will answer.
alepha/server ships the common ones, each an HttpError.extend to derive from:
| Class | name |
Status |
|---|---|---|
BadRequestError |
BAD_REQUEST |
400 |
ValidationError |
VALIDATION_FAILED |
400 |
UnauthorizedError |
UNAUTHORIZED |
401 |
ForbiddenError |
FORBIDDEN |
403 |
NotFoundError |
NOT_FOUND |
404 |
ConflictError |
CONFLICT |
409 |
TooManyRequestsError |
TOO_MANY_REQUESTS |
429 |
InternalServerError |
INTERNAL_SERVER_ERROR |
500 |
ServiceUnavailableError |
SERVICE_UNAVAILABLE |
503 |
#Declaring them on an action
errors lists what the handler may throw: errors derived from HttpError, the built-in ones included.
1import { $inject, z } from "alepha"; 2import { $action, HttpError, NotFoundError } from "alepha/server"; 3 4const ProjectQuotaExceeded = HttpError.extend({ 5 name: "PROJECT_QUOTA_EXCEEDED", 6 default: { status: 409 }, 7 schema: z.object({ limit: z.number(), used: z.number() }), 8}); 9 10class ProjectService {11 async create(name: string) {12 return { id: name };13 }14}15 16class ProjectController {17 projects = $inject(ProjectService);18 19 createProject = $action({20 method: "POST",21 path: "/createProject",22 errors: [ProjectQuotaExceeded, NotFoundError],23 schema: {24 body: z.object({ name: z.text() }),25 response: z.object({ id: z.text() }),26 },27 handler: ({ body }) => this.projects.create(body.name),28 });29}
Setting errors turns on strict mode for that action. The filter wraps the handler only, and sorts what it throws:
- An error matching a declared class (
instanceof, so a declared parent admits its children) reaches the client with its status anddata. - Anything else becomes a
500 InternalServerError, including an undeclaredHttpErrorsuch as aNotFoundErrorthe list forgot. In production the body is byte for byte the one any other crash answers: no message, no cause. Outside production the message passes through, prefixed with the error's name. Either way a warning names the action and the error:'createProject' threw NOT_FOUND, which it does not declare. errors: []is strict too: nothing is expected.- Omitting
errorschanges nothing: anyHttpErrorpasses with its status, as before.
#What always passes
Only what the handler, and the services it calls, throw is filtered. The framework's own answers sit outside the filter and never need declaring:
| Source | Status |
|---|---|
| Request validation (body, query...) | 400 |
$secure |
401 / 403 |
$rateLimit |
429 |
The action's timeout (TimeoutError) |
503 |
A database timeout (DbTimeoutError) |
503 |
Any use middleware is outside the filter, and so is any error carrying a 503 (in itself or in its cause chain): a timeout tells the caller to retry, it is never a contract violation.
The ORM's errors are HTTP errors too, so a strict action declares them like any other: DbEntityNotFoundError (404) for a lookup by id that missed, DbConflictError (409) for a unique constraint.
#On the wire
A declared error answers its status with a JSON body built from toJSON, the request id added to data:
1{2 "name": "PROJECT_QUOTA_EXCEEDED",3 "message": "PROJECT_QUOTA_EXCEEDED",4 "data": { "status": 409, "limit": 5, "used": 6, "requestId": "..." }5}
The message here is the name because ProjectQuotaExceeded declares none. A 4xx keeps its message and its cause ({ name, message }), which are deliberate, client-facing context. ⚠️ A 5xx in production loses both, declared or not: the message becomes Internal Server Error. Put what the client must read in data, or use a 4xx.
#OpenAPI
A strict action's operation documents every declared error under its default status, with name fixed and data typed by its schema (two errors on one status become a oneOf), the responses its middlewares declare (401, 403, 429), and the 500. An error whose status has no default is documented as the default response. Every action, strict or not, also documents 400 when it validates input and the 503 TimeoutError when it has a timeout. Actions without errors are otherwise documented as before. See OpenAPI.
A middleware of your own documents its responses by setting the MIDDLEWARE_RESPONSES meta key to a comma-separated list of statuses. $secure sets "401" or "401,403", $rateLimit sets "429":
1import { createMiddleware, MIDDLEWARE_RESPONSES } from "alepha"; 2import { HttpError } from "alepha/server"; 3 4const maintenance = { on: false }; 5 6export const notInMaintenance = () => 7 createMiddleware({ 8 name: "notInMaintenance", 9 meta: { [MIDDLEWARE_RESPONSES]: "423" },10 handler:11 ({ next }) =>12 async (...args: any[]) => {13 if (maintenance.on) throw new HttpError({ status: 423 });14 return next(...args);15 },16 });
Like any use middleware, what it throws sits outside the filter, so the 423 needs no declaring.
#Branching in React
Every declared error registers under its name when its module is imported, and HttpClient rebuilds the registered class from the response body ({ name, message, data }). A page that writes instanceof ProjectQuotaExceeded has imported it, so the check holds in the browser:
1import { useAction } from "alepha/react"; 2 3const create = useAction({ 4 handler: (_ctx, name: string) => api.createProject({ body: { name } }), 5 onError: (error) => { 6 if (error instanceof ProjectQuotaExceeded) { 7 // error.data is { status: number; limit: number; used: number; ... } 8 openUpgradeDialog(error.data.limit); 9 }10 },11});
RunResult's error stays Error; instanceof is what narrows it, on result.error as in onError. An onError marks the failure handled, so the app's ActionErrorToaster stays quiet.
Built-in errors rebuild too: a 404 from the framework is a NotFoundError in the browser. A name nothing registered, or data failing its schema, stays a plain HttpError.
Where a function needs the whole union an action may reject with, ActionErrors<typeof action> names it: the declared errors plus InternalServerError.
#Finding an error under another
A library may wrap an error: drizzle demotes the driver's error to cause and throws its own. X.find(error) returns the X in an error or its cause chain:
1import { DbTimeoutError } from "alepha/orm";2 3const isTimeout = (error: unknown) => DbTimeoutError.find(error) !== undefined;
The search follows cause at most ten levels deep, so a chain that loops on itself cannot hang.
#The one limit
TypeScript has no checked exceptions. Nothing proves at compile time that a service throws only what the action declares; the warning logged for an undeclared error is what keeps the list honest.