Error Handling

Handle errors with middleware and guards

JustScale provides flexible error handling patterns through middleware, guards, and response helpers. Errors can be handled at multiple levels to create robust, maintainable applications.

Response Error Helper

The simplest way to send error responses is using res.error():

src/controllers/players.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { PlayerService } from '../services/player-service';

export const PlayersController = createController('/players', {
  inject: { players: PlayerService },
  routes: (services) => ({
    getOne: Get('/:id').handle(async ({ params, res }) => {
      const player = await services.players.findById(params.id);

      if (!player) {
        res.error('Player not found', 404);
        return;
      }

      res.json({ player });
    }),
  }),
});

This sends:

JSON
{
  "error": "Player not found"
}

Validation Errors

Validation middleware automatically handles errors:

src/controllers/players.tsTypeScript
import { z } from 'zod';
import { createController } from '@justscale/core';
import { Post, body } from '@justscale/http/builder';
import { PlayerService } from '../services/player-service';

const CreatePlayerSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  chips: z.number().positive(),
});

export const PlayersController = createController('/players', {
  inject: { players: PlayerService },
  routes: (services) => ({
    create: Post('/')
      .apply(body(CreatePlayerSchema))
      .handle(async ({ body, res }) => {
        // If validation fails, parseBody calls res.error() and throws
        // This handler is never reached with invalid data
        const player = await services.players.save(body);
        res.json({ player });
      }),
  }),
});

Invalid request returns:

JSON
{
  "error": "Validation failed: Name is required"
}

Middleware Error Handling

Middleware can throw errors to stop the request:

src/controllers/profile.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { requireAuth } from '../middleware/require-auth';

export const ProfileController = createController('/profile', {
  routes: () => ({
    get: Get('/')
      .use(requireAuth)
      .handle(({ user, res }) => {
        // Only reached if authentication succeeds
        res.json({ user });
      }),
  }),
});

Guards for Authorization

Guards provide a cleaner pattern for authorization checks:

src/controllers/profile.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { IsAuthenticated } from '../guards/is-authenticated';

export const ProfileController = createController('/profile', {
  routes: () => ({
    get: Get('/')
      .guard(IsAuthenticated)
      .handle(({ user, res }) => {
        res.json({ user });
      }),
  }),
});

Guard Composition

Combine multiple guards:

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Delete } from '@justscale/http';
import { IsAuthenticated } from '../guards/is-authenticated';
import { IsAdmin } from '../guards/is-admin';
import { UserService } from '../services/user-service';

export const UsersController = createController('/users', {
  inject: { users: UserService },
  routes: (services) => ({
    delete: Delete('/:id')
      .guard(IsAuthenticated)
      .guard(IsAdmin)
      .handle(async ({ params, res }) => {
        // Only admins can delete users
        await services.users.deleteById(params.id);
        res.json({ success: true });
      }),
  }),
});

Service-Level Errors

Throw domain-specific errors from services:

src/controllers/games.tsTypeScript
import { createController } from '@justscale/core';
import { Post, populate } from '@justscale/http';
import { GameRepository } from '../repositories/game-repository';
import { GameService } from '../services/game-service';
import { requireAuth } from '../middleware/require-auth';

export const GamesController = createController('/games', {
  inject: { games: GameRepository, gameService: GameService },
  routes: (services) => ({
    join: Post('/:gameId/join')
      .use(populate(services.games, 'game', 'gameId'))
      .use(requireAuth)
      .handle(async ({ game, user, res }) => {
        try {
          await services.gameService.joinGame(game.id, user.id);
          res.json({ success: true });
        } catch (error) {
          if (error.message === 'Game is full') {
            res.error('Cannot join: game is full', 409);
          } else if (error.message === 'Game already started') {
            res.error('Cannot join: game already started', 409);
          } else {
            res.error('Failed to join game', 500);
          }
        }
      }),
  }),
});

Custom Error Classes

Create typed error classes for better error handling:

src/controllers/players.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { PlayerService } from '../services/player-service';
import { errorHandler } from '../utils/error-handler';

export const PlayersController = createController('/players', {
  inject: { playerService: PlayerService },
  routes: (services) => ({
    getOne: Get('/:id').handle(async ({ params, res }) => {
      try {
        const player = await services.playerService.getPlayer(params.id);
        res.json({ player });
      } catch (error) {
        errorHandler(error, res);
      }
    }),
  }),
});

HTTP Status Codes

Use appropriate status codes for different error types:

  • 400 - Bad request (client error)
  • 401 - Unauthorized (missing/invalid auth)
  • 403 - Forbidden (insufficient permissions)
  • 404 - Not found
  • 409 - Conflict (e.g., duplicate resource)
  • 422 - Unprocessable entity (validation)
  • 500 - Internal server error

Error Response Formats

Simple Error

example.tsTypeScript
res.error('Not found', 404);
JSON
{
  "error": "Not found"
}

Detailed Error

example.tsTypeScript
res.json({
  error: 'Validation failed',
  details: [
    { field: 'email', message: 'Invalid email format' },
    { field: 'password', message: 'Too short' },
  ],
}, 422);
JSON
{
  "error": "Validation failed",
  "details": [
    { "field": "email", "message": "Invalid email format" },
    { "field": "password", "message": "Too short" }
  ]
}

Best Practices

  • Fail fast - Validate early with middleware and guards
  • Use custom error classes - Type-safe error handling
  • Be consistent - Use the same error format across your API
  • Don't leak sensitive info - Hide stack traces in production
  • Log errors - Always log unexpected errors for debugging
  • Use appropriate status codes - Help clients understand errors
utils/error-response.tsTypeScript
interface ErrorResponse {
  error: string;
  details?: unknown;
  code?: string;
}

const sendError = (res: any, message: string, status: number, details?: unknown) => {
  const response: ErrorResponse = { error: message };

  if (details) {
    response.details = details;
  }

  res.json(response, status);
};