alepha@docs:~/docs/guides/server$
cat 3-error-handling.md
4 min read
Last commit:

#Error Handling

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.

#The Error Hierarchy

All errors thrown by Alepha extend AlephaError. This is the base class that ensures consistent behavior across the framework:

bash
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.

#HTTP Errors

For web applications, use the specific error classes from alepha/server:

typescript
 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:

json
1{2  "error": "NotFoundError",3  "message": "User not found",4  "status": 4045}

#The Error Response Schema

All HTTP errors follow a consistent schema. This isn't just convention—it's enforced:

typescript
 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:

typescript
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.

#Available Error Classes

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:

typescript
 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}

#The status Field Matters

Every error in Alepha should have a status field. This is critical for HTTP responses:

  • Errors with status return that status code to the client
  • Errors without status become 500 Internal Server Error

You 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.

typescript
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.

#Checking Error Types with 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:

typescript
 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.

#Database Errors

The ORM module throws errors with proper status fields automatically:

typescript
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:

typescript
 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}

#Schema Validation Errors

When TypeBox schema validation fails, Alepha throws a TypeBoxError:

typescript
 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:

json
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.

#Error Chaining with cause

All Alepha errors support the standard cause property for error chaining. This lets you see the full error trail:

typescript
 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:

bash
BadRequestError: Payment failed
  └── cause: StripeError: Card declined
        └── cause: NetworkError: Connection timeout

#Global Error Handling

#Server-Side

Use a hook to intercept all errors:

typescript
 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}

#Client-Side (React)

Use the event system:

typescript
 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.

#Error Handling Patterns

#Transform at Boundaries

Convert internal errors to HTTP errors at the API layer:

typescript
 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}

#Graceful Degradation

For non-critical features, fail silently:

typescript
 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}

#Summary

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:

  • All errors should extend AlephaError
  • All errors should have a status field
  • 500 errors mean something is wrong with your error handling
  • Use cause to chain errors for debugging
On This Page
No headings found...
ready
mainTypeScript
UTF-8guides_server_error_handling.md