Skip to content

Consistent Responses

BananaJS enforces a single response contract across every handler. Every response — success or error — shares the same envelope, so clients parse responses the same way regardless of route.

The envelope

All responses carry three fields:

typescript
{
  statusCode: 'success' | 'error'   // always present
  status:     number                 // HTTP status code
  message:    string                 // human-readable label
  data?:      T                      // success only; omitted on errors
}

Undefined fields are stripped before sending, so error responses never have a data key in the JSON.

Success responses

Via BaseController.ok()

The preferred path. Extend BaseController and call this.ok(res, message, data) from any handler:

typescript
import { Controller, Get, BaseController } from '@banana-universe/bananajs'
import type { Request, Response } from 'express'

@Controller('users')
export class UserController extends BaseController {
  @Get(':id')
  async getOne(req: Request, res: Response) {
    const user = await UserService.findById(req.params.id)
    return this.ok(res, 'User fetched', user)
  }
}

The signature:

typescript
protected ok<T>(res: Response, message: string, data: T): Response

JSON output:

json
{
  "statusCode": "success",
  "status": 200,
  "message": "User fetched",
  "data": { "id": "1", "name": "Alice" }
}

Via SuccessResponse directly

When you need more control (custom headers, use outside a BaseController):

typescript
import { SuccessResponse } from '@banana-universe/bananajs'

new SuccessResponse('Export ready', { fileId: 'abc' }).send(res, {
  'x-export-id': 'abc',
})

send(res, headers?) accepts an optional { [key: string]: string } headers map.

Paginated responses

PaginatedResponse<T> extends SuccessResponse<T> and adds pagination metadata. Use it when returning list endpoints that support offset or cursor pagination. The shape and status code are identical to a plain success response — clients see the same outer envelope, with pagination fields appended inside data (or alongside it, depending on your list handler).

Error responses

Throw a typed error class

Throw any ApiError subclass from a handler. The ErrorMiddleware catches it and calls ApiError.handle() to produce the right response shape — you never call res.status().json() manually.

typescript
import { Controller, Get, BaseController, NotFoundError } from '@banana-universe/bananajs'
import type { Request, Response } from 'express'

@Controller('users')
export class UserController extends BaseController {
  @Get(':id')
  async getOne(req: Request, res: Response) {
    const user = await UserService.findById(req.params.id)
    if (!user) throw new NotFoundError('User not found')
    return this.ok(res, 'User fetched', user)
  }
}

JSON output:

json
{
  "statusCode": "error",
  "status": 404,
  "message": "User not found"
}

How errors flow

handler throws ApiError subclass
  └─ Express passes it to ErrorMiddleware (4-arg handler)
       └─ instanceof ApiError → ApiError.handle(err, res)
            └─ switch(err.type) → correct ApiResponse subclass.send(res)
       └─ otherwise → InternalError (message masked in production)

Available error classes

ClassHTTPErrorTypeDefault message
BadRequestError400BAD_REQUEST'Bad Parameters'
UnauthorisedError401UNAUTHORISED
PaymentRequiredError402PAYMENT_REQUIRED
ForbiddenError403FORBIDDEN
NotFoundError404NOT_FOUND
ConflictError409CONFLICT
TooManyRequestsError429TOO_MANY_REQUESTS
InternalError500INTERNAL_ERROR'Something wrong happened.'
BadGatewayError502BAD_GATEWAY
ServiceUnavailableError503SERVICE_UNAVAILABLE
GatewayTimeoutError504GATEWAY_TIMEOUT

All are exported from @banana-universe/bananajs.

Production masking

In production (NODE_ENV === 'production'), any error not explicitly typed as an ApiError subclass is caught by ErrorMiddleware, wrapped as InternalError, and its message is replaced with 'Something wrong happened.'. This prevents accidental leakage of stack traces or internal error text.

Typed ApiError subclasses always send their own message unchanged, in all environments.

Validation errors

The validation decorators — @Body, @Query, @Params, @Headers — accept validation schemas and throw BadRequestError internally when parsing fails. The client receives the same envelope:

json
{
  "statusCode": "error",
  "status": 400,
  "message": "Invalid input"
}

No additional work is needed to wire validation errors into the response contract.

Best practices

  • Use this.ok() from BaseController for all success responses — avoid calling res.json() directly.
  • Throw typed ApiError subclasses instead of res.status(xxx).json(...) so the contract is enforced at the middleware level.
  • Keep message stable and user-facing; put machine-readable details (IDs, counts, lists) inside data.
  • Do not catch and swallow ApiError in handlers — let ErrorMiddleware handle it.
  • In catch blocks, always return next(error) (the return is required because noImplicitReturns is enabled):
typescript
@Get(':id')
async getOne(req: Request, res: Response, next: NextFunction) {
  try {
    const user = await UserService.findById(req.params.id)
    if (!user) throw new NotFoundError('User not found')
    return this.ok(res, 'User fetched', user)
  } catch (error) {
    return next(error)  // ← return is required
  }
}

Released under the MIT License.