Every framework promises "great error handling." Then you get a stack trace 47 levels deep pointing to some internal middleware you've never seen.
Alepha tries to be honest about errors. When something breaks, you should know what, where, and why.
All errors thrown by Alepha extend AlephaError. This is the base class that ensures consistent behavior across the framework:
AlephaError (base)
├── HttpError (server errors with status codes)
│ ├── BadRequestError (400)
│ ├── UnauthorizedError (401)
│ ├── ForbiddenError (403)
│ ├── NotFoundError (404)
│ ├── ConflictError (409)
│ └── ValidationError (400)
├── DbError (500)
│ ├── DbEntityNotFoundError (404)
│ ├── DbConflictError (409)
│ └── DbVersionMismatchError (409)
└── TypeBoxError (400)
If you see an error that doesn't extend AlephaError, that's a bug.
Either in your code or in the framework. All framework modules use this hierarchy consistently.
For web applications, use the specific error classes from alepha/server:
1import { 2 NotFoundError, 3 BadRequestError, 4 UnauthorizedError, 5 ForbiddenError, 6 ConflictError, 7} from "alepha/server"; 8 9class UserController {10 repo = $repository(userEntity);11 12 // Repository methods already throw DbEntityNotFoundError (404) if not found13 // No need to check for null!14 getUser = $action({15 path: "/users/:id",16 handler: async ({ params }) => {17 return await this.repo.findById(params.id);18 },19 });20}21 22// When working with non-ORM data sources, throw errors explicitly23class ConfigController {24 protected configs = new Map<string, Config>();25 26 getConfig = $action({27 path: "/configs/:key",28 handler: async ({ params }) => {29 const config = this.configs.get(params.key);30 31 if (!config) {32 throw new NotFoundError(`Config '${params.key}' not found`);33 }34 35 return config;36 },37 });38}
The client receives a clean JSON error:
1{2 "error": "NotFoundError",3 "message": "User not found",4 "status": 4045}
All HTTP errors follow a consistent schema. This isn't just convention—it's enforced:
1{ 2 error: string; // Error class name ("NotFoundError", "ValidationError", etc.) 3 status: number; // HTTP status code 4 message: string; // Human-readable description 5 details?: string; // Optional detailed explanation 6 requestId?: string; // Request tracking ID (when enabled) 7 cause?: { // Original error (development only) 8 name: string; 9 message: string;10 };11}
Need the schema or type in your code? Import it:
1import { errorSchema, type ErrorSchema } from "alepha/server";
This schema is used for OpenAPI documentation, client-side type inference, and response serialization. Your frontend can rely on this shape for every error, every time.
| Class | Status | Default Message |
|---|---|---|
BadRequestError |
400 | "Invalid request body" |
UnauthorizedError |
401 | "Not allowed to access this resource" |
ForbiddenError |
403 | "No permission to access this resource" |
NotFoundError |
404 | "Resource not found" |
ConflictError |
409 | "Entity already exists" |
ValidationError |
400 | "Validation has failed" |
For custom status codes, use HttpError directly:
1import { HttpError } from "alepha/server"; 2 3class TeapotController { 4 brew = $action({ 5 path: "/brew", 6 handler: async () => { 7 throw new HttpError({ status: 418, message: "I'm a teapot" }); 8 }, 9 });10}
status Field MattersEvery error in Alepha should have a status field. This is critical for HTTP responses:
status return that status code to the clientstatus become 500 Internal Server ErrorYou should never have 500 errors by design. A 500 means something unexpected happened. If you know an error can occur, give it a proper status code.
1// Bad: This becomes a 5002throw new Error("User not found");3 4// Good: This is a proper 4045throw new NotFoundError("User not found");6 7// Even better: Domain-specific error8throw new ProductNotFoundError(productId);
Domain-specific errors are more expressive. ProductNotFoundError tells you exactly what's missing. EmptyStockError tells you why the order failed. Generic NotFoundError makes you dig through logs. Name your errors after business concepts, not HTTP codes.
HttpError.is()The HttpError.is() static method is essential for error handling. It checks if an error is HTTP-like and optionally matches a specific status:
1import { HttpError } from "alepha/server"; 2 3class UserService { 4 async getOrCreateUser(email: string) { 5 try { 6 return await this.api.getUser.run({ params: { email } }); 7 } catch (error) { 8 // Check if it's any HTTP error 9 if (HttpError.is(error)) {10 console.log("HTTP error with status:", error.status);11 }12 13 // Check for a specific status code14 if (HttpError.is(error, 404)) {15 // User not found, create one16 return await this.createUser(email);17 }18 19 // Check for multiple statuses20 if (HttpError.is(error, 401) || HttpError.is(error, 403)) {21 // Auth issue22 throw new UnauthorizedError("Please log in first", error);23 }24 25 // Unknown error, re-throw26 throw error;27 }28 }29}
HttpError.is() works with any error that has status and message properties, not just HttpError instances. This makes it compatible with errors from the ORM and other modules.
The ORM module throws errors with proper status fields automatically:
1class UserService {2 repo = $repository(userEntity);3 4 async updateUser(id: string, data: UserUpdate) {5 // This throws DbEntityNotFoundError (status: 404) if user doesn't exist6 return await this.repo.updateById(id, data);7 }8}
Available database errors:
| Class | Status | When |
|---|---|---|
DbEntityNotFoundError |
404 | findById, updateById, deleteById when entity missing |
DbConflictError |
409 | Unique constraint violation |
DbVersionMismatchError |
409 | Optimistic locking conflict |
Because these errors have status, they automatically translate to proper HTTP responses:
1class UserController { 2 updateUser = $action({ 3 path: "/users/:id", 4 schema: { body: userUpdateSchema }, 5 handler: async ({ params, body }) => { 6 // If user not found, client gets 404 automatically 7 return await this.repo.updateById(params.id, body); 8 }, 9 });10}
When TypeBox schema validation fails, Alepha throws a TypeBoxError:
1class UserController { 2 createUser = $action({ 3 schema: { 4 body: t.object({ 5 email: t.email(), 6 age: t.integer({ minimum: 18 }), 7 }), 8 }, 9 handler: async ({ body }) => {10 // body is guaranteed valid here11 },12 });13}
If someone sends { email: "not-an-email", age: 15 }, they get a 400:
1{2 "error": "ValidationError",3 "status": 400,4 "message": "Invalid request body",5 "cause": {6 "name": "TypeBoxError",7 "message": "Invalid email format at /email"8 }9}
You don't write validation code. You don't catch validation errors. They just work.
causeAll Alepha errors support the standard cause property for error chaining. This lets you see the full error trail:
1class PaymentService { 2 async processPayment(userId: string, amount: number) { 3 try { 4 await this.stripe.charge(userId, amount); 5 } catch (stripeError) { 6 // Wrap the original error as the cause 7 throw new BadRequestError("Payment failed", stripeError); 8 } 9 }10}
The error chain is preserved:
BadRequestError: Payment failed
└── cause: StripeError: Card declined
└── cause: NetworkError: Connection timeout
Use a hook to intercept all errors:
1class ErrorHandler { 2 log = $logger(); 3 4 onError = $hook({ 5 on: "server:onError", 6 handler: async ({ error, request }) => { 7 // Log every error 8 this.log.error("Request failed", { 9 url: request.url,10 error: error.message,11 status: HttpError.is(error) ? error.status : 500,12 });13 14 // Send to Sentry15 Sentry.captureException(error, {16 extra: { url: request.url },17 });18 },19 });20}
Use the event system:
1class ErrorHandler { 2 log = $logger(); 3 4 onError = $hook({ 5 // catch all action errors in React (from useAction, useForm and router) 6 on: "react:action:error", 7 handler: async ({ error, request }) => { 8 // Log to analytics 9 analytics.track("error", {10 message: error.message,11 action: type,12 });13 },14 });15}
One listener. Every error. No try/catch everywhere.
Convert internal errors to HTTP errors at the API layer:
1class UserController { 2 getUser = $action({ 3 path: "/users/:id", 4 handler: async ({ params }) => { 5 try { 6 return await this.userService.findById(params.id); 7 } catch (error) { 8 if (error instanceof UserNotFoundError) { 9 throw new NotFoundError(error.message, error);10 }11 throw error; // Re-throw unknown errors12 }13 },14 });15}
For non-critical features, fail silently:
1class UserController { 2 getUser = $action({ 3 path: "/users/:id", 4 handler: async ({ params }) => { 5 const user = await this.users.findById(params.id); 6 7 // Recommendations are nice to have, not critical 8 let recommendations = []; 9 try {10 recommendations = await this.recommendationService.get(params.id);11 } catch (e) {12 this.log.warn("Recommendations failed, continuing without");13 }14 15 return { ...user, recommendations };16 },17 });18}
| Scenario | Solution |
|---|---|
| Resource not found | throw new NotFoundError() |
| Invalid input | Let schema validation handle it |
| Auth failure | throw new UnauthorizedError() |
| No permission | throw new ForbiddenError() |
| Duplicate entity | throw new ConflictError() |
| Business rule violation | Custom error extending AlephaError with status |
| Check error type | HttpError.is(error, 404) |
| Global logging | $hook({ on: "server:onError" }) |
Remember:
AlephaErrorstatus fieldcause to chain errors for debugging