#Typed errors
A $action can declare the errors it answers with. Declared errors are part of its contract: they reach the client with their status and a typed data payload, 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 an HttpError subclass. Declare it once, at module level, in a file the browser can load (it imports nothing server-only):
1// src/errors/QuotaExceeded.ts 2import { z } from "alepha"; 3import { $error } from "alepha/server"; 4 5export const QuotaExceeded = $error({ 6 code: "QuotaExceeded", 7 status: 409, 8 data: z.object({ limit: z.number(), used: z.number() }), 9});10 11// Optional: a name for function signatures.12export type QuotaExceeded = InstanceType<typeof QuotaExceeded>;13 14export const ProjectArchived = $error({15 code: "ProjectArchived",16 status: 409,17 message: "This project is archived",18});
codeis required. It is what theerrorfield of the JSON body carries, so write it in PascalCase and make it equal to the constant's name, the same vocabulary as the built-in"NotFoundError". The factory cannot see the constant's name, so it has to be spelled out.statusis the HTTP status it answers with.datais an optionalz.objectschema. With one, the constructor takes the payload first and it is type-checked; without one, every argument is optional.messageis the default message. Without it, the status text ("Conflict").
#Throwing it
Any layer can throw it. A service needs no reference to the action that will answer:
1import { z } from "alepha"; 2import { $error } from "alepha/server"; 3 4const QuotaExceeded = $error({ 5 code: "QuotaExceeded", 6 status: 409, 7 data: z.object({ limit: z.number(), used: z.number() }), 8}); 9 10class ProjectService {11 async create(used: number) {12 if (used >= 5) {13 throw new QuotaExceeded({ limit: 5, used });14 }15 // A message or a cause of your own goes last.16 throw new QuotaExceeded(17 { limit: 5, used },18 { message: "Upgrade to create more projects" },19 );20 }21}
#Declaring it on an action
errors lists what the handler may throw: $error constants and built-in classes (NotFoundError, ForbiddenError, ...) alike.
1import { $inject, z } from "alepha"; 2import { $action, $error, NotFoundError } from "alepha/server"; 3 4const QuotaExceeded = $error({ 5 code: "QuotaExceeded", 6 status: 409, 7 data: 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 errors: [QuotaExceeded, NotFoundError],21 schema: {22 body: z.object({ name: z.text() }),23 response: z.object({ id: z.text() }),24 },25 handler: ({ body }) => this.projects.create(body.name),26 });27}
Setting errors turns on strict mode for that action:
- An error matching a declared class (
instanceof) 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 class name. Either way a warning names the action and the class:'createProject' threw NotFoundError, 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 |
A 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.
⚠️ A repository lookup that misses throws DbEntityNotFoundError, which carries a 404 but is not an HttpError, so strict mode turns it into a 500. Translate it where you look the row up:
1try {2 return await this.repo.getById(id);3} catch (error) {4 if (error instanceof DbEntityNotFoundError) {5 throw new NotFoundError("Project not found", error);6 }7 throw error;8}
#OpenAPI
A strict action's operation documents every declared error under its status, with error fixed to its code and data typed by its schema (two errors on one status become a oneOf), the responses its middlewares declare (401, 403, 429), 400 when it validates input, and the 500. Actions without errors are 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.
#Branching in React
HttpClient rebuilds any error whose code is registered, and an $error constant registers itself when its module is imported. A page that writes instanceof QuotaExceeded 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 QuotaExceeded) { 7 // error.data is { 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 classes rebuild too: a 404 from the framework is a NotFoundError in the browser. A code 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 classes plus InternalServerError.
#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.