DeesseJS RPC

ServerError

Server exceptions, error codes, and status codes

Server exceptions let you signal errors from your handlers in a structured way. Instead of throwing exceptions that crash your server, you return errors using the err() helper. Each exception carries a code, message, and HTTP status code that the framework translates into proper API responses.

Overview

When you need to signal an error in your handler, you use the err() function from @deessejs/fp with an exception instance. The framework catches these exceptions and converts them to appropriate HTTP responses with the correct status code and error format.

The ServerException class is the foundation for all built-in exceptions. It extends the native Error class and adds properties for error codes, HTTP status codes, and optional additional data.

Here is a basic example showing how to return a not found error:

api.ts
import { defineContext } from '@deessejs/server'
import { z } from 'zod'
import { ok, err } from '@deessejs/fp'
import { NotFoundException } from '@deessejs/server'

const { t, createAPI } = defineContext({
  context: () => ({ db: myDatabase }),
})

const getUser = t.query({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    const user = await ctx.db.users.find(args.id)

    if (!user) {
      return err(new NotFoundException('User not found'))
    }

    return ok(user)
  },
})

Built-in Exceptions

The framework provides several built-in exceptions for common error scenarios. Each exception maps to a specific HTTP status code and has a predefined error code.

NotFoundException

Use this exception when a requested resource does not exist. It returns HTTP 404 and the error code NOT_FOUND.

The following example shows how to return a not found error when a user does not exist:

handler: async (ctx, args) => {
  const user = await ctx.db.users.find(args.id)

  if (!user) {
    return err(new NotFoundException('User not found'))
  }

  return ok(user)
}

UnauthorizedException

Use this exception when a request lacks authentication or has invalid credentials. It returns HTTP 401 and the error code UNAUTHORIZED.

The example below demonstrates how to check for authentication and return an unauthorized error:

handler: async (ctx, args) => {
  if (!ctx.auth || !ctx.auth.userId) {
    return err(new UnauthorizedException('Authentication required'))
  }

  return ok({ userId: ctx.auth.userId })
}

ValidationException

Use this exception when input validation fails, such as invalid email formats, duplicate entries, or any business logic validation error. It returns HTTP 400 and the error code VALIDATION_ERROR.

The example below shows how to validate a unique constraint and return a validation error:

handler: async (ctx, args) => {
  const existing = await ctx.db.users.findByEmail(args.email)

  if (existing) {
    return err(new ValidationException('Email already exists'))
  }

  const user = await ctx.db.users.create(args)
  return ok(user)
}

Exception Properties

Prop

Type

Creating Custom Exceptions

When you need domain-specific errors beyond the built-in exceptions, you can extend ServerException to create your own exception classes. This lets you define custom error codes and HTTP status codes that fit your application's needs.

To create a custom exception, you extend ServerException and call the parent constructor with your error code, message, and status code.

The example below shows how to create a ForbiddenException for access control scenarios:

api.ts
import { defineContext } from '@deessejs/server'
import { ServerException } from '@deessejs/server'

class ForbiddenException extends ServerException {
  constructor(message = 'Access denied') {
    super('FORBIDDEN', message, 403)
    this.name = 'ForbiddenException'
  }
}

const { t, createAPI } = defineContext({
  context: () => ({ db: myDatabase }),
})

const deleteUser = t.mutation({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    const currentUser = await ctx.db.users.find(ctx.auth.userId)

    if (!currentUser?.isAdmin) {
      return err(new ForbiddenException('Only administrators can delete users'))
    }

    await ctx.db.users.delete(args.id)
    return ok({ success: true })
  },
})

You can also pass additional data to your exceptions by providing a fourth argument to the constructor. This is useful when you need to include extra context about the error.

The following example shows how to include additional data with a custom exception:

class QuotaExceededException extends ServerException {
  constructor(current: number, limit: number) {
    super('QUOTA_EXCEEDED', 'Storage quota exceeded', 403, { current, limit })
    this.name = 'QuotaExceededException'
  }
}

// Usage
return err(new QuotaExceededException(150, 100))

HTTP Status Codes

Each exception maps to a specific HTTP status code that clients use to understand the result of their request. When you return an error, the framework translates your exception into the appropriate HTTP response.

The table below shows the built-in exceptions and their corresponding HTTP status codes:

ExceptionStatus CodeDescription
ServerException (base)500Internal server error
NotFoundException404Resource not found
UnauthorizedException401Authentication required
ValidationException400Bad request or validation failure
ForbiddenException (custom)403Access denied
ConflictException (custom)409Resource conflict

When you create custom exceptions, you choose the status code that best fits your error scenario. Use 400 for client errors, 401 for authentication issues, 403 for authorization failures, 404 for missing resources, and 409 for conflicts.

Error Codes

Error codes are machine-readable strings that clients can use to programmatically handle different error types. The framework provides a predefined list of error codes through the ErrorCodes constant.

To use error codes in your handlers, import ErrorCodes and reference the appropriate code:

api.ts
import { defineContext, ErrorCodes } from '@deessejs/server'
import { z } from 'zod'
import { ok, err } from '@deessejs/fp'
import { ServerException } from '@deessejs/server'

const { t, createAPI } = defineContext({
  context: () => ({ db: myDatabase }),
})

// Using ErrorCodes for custom exceptions
class BusinessRuleException extends ServerException {
  constructor(message: string) {
    super(ErrorCodes.CONFLICT, message, 409)
    this.name = 'BusinessRuleException'
  }
}

const placeOrder = t.mutation({
  args: z.object({ productId: z.number() }),
  handler: async (ctx, args) => {
    const product = await ctx.db.products.find(args.productId)

    if (!product) {
      return err(new NotFoundException('Product not found'))
    }

    if (product.stock === 0) {
      return err(new BusinessRuleException('Product is out of stock'))
    }

    return ok({ orderId: 123 })
  },
})

The ErrorCodes constant includes the following predefined codes:

CodeDescription
NOT_FOUNDResource not found
UNAUTHORIZEDAuthentication required
VALIDATION_ERRORInput validation failed
FORBIDDENAccess denied
CONFLICTResource conflict
INTERNAL_ERRORInternal server error
ROUTE_NOT_FOUNDAPI route not found
INVALID_ARGSInvalid procedure arguments

Best Practices

When you design your error handling strategy, consider the following guidelines to create consistent and useful error responses for your API consumers.

Use the Most Specific Exception

Choose the exception that most accurately describes the error. When a resource is not found, use NotFoundException. When validation fails, use ValidationException. This helps clients handle errors appropriately without inspecting error messages.

The following example shows the correct exception for each scenario:

// Don't - use the wrong exception type
if (!user) {
  return err(new ValidationException('User not found'))
}

// Do - use the appropriate exception
if (!user) {
  return err(new NotFoundException('User not found'))
}

Provide Meaningful Messages

When you create error messages, be specific enough that clients can understand what went wrong and how to fix it. Avoid generic messages like "An error occurred" or "Invalid input."

The example below shows how to write helpful error messages:

// Don't - vague error message
return err(new ValidationException('Invalid input'))

// Do - specific error message
return err(new ValidationException('Email must be a valid email address'))

// Do - include actionable information
return err(new ValidationException('User with email user@example.com already exists'))

Return Errors Explicitly

Always use err() to return errors from your handlers. This makes error handling explicit and allows the framework to properly serialize errors for clients.

The pattern below demonstrates the correct way to handle errors:

handler: async (ctx, args) => {
  // Validate input
  if (!args.email.includes('@')) {
    return err(new ValidationException('Invalid email format'))
  }

  // Check for existing resource
  const existing = await ctx.db.users.findByEmail(args.email)
  if (existing) {
    return err(new ValidationException('Email already registered'))
  }

  // Success case
  const user = await ctx.db.users.create(args)
  return ok(user)
}

Handle Errors at the Boundary

When you have middleware or lifecycle hooks, handle errors at the appropriate boundary. Do not catch and swallow exceptions in your handlers if they should propagate to clients.

The following pattern shows proper error handling:

const getUser = t.query({
  args: z.object({ id: z.number() }),
  handler: async (ctx, args) => {
    // Let the framework handle database errors
    // Return only expected errors (not found, validation)
    const user = await ctx.db.users.find(args.id)

    if (!user) {
      return err(new NotFoundException(`User ${args.id} not found`))
    }

    return ok(user)
  },
})

Next Steps

On this page