Skip to Content
DocumentationHTTPError Handling

Error Handling

Morphis catches errors thrown anywhere in the request pipeline and turns them into HTTP responses.

That includes errors raised in:

  • middleware
  • controllers
  • services
  • model and provider calls that bubble upward

The practical rule is simple: throw domain-appropriate errors from the service layer, then let the router normalize them into the final response.

How Morphis turns errors into responses

At runtime, Morphis does three things:

  • normalizes the thrown value into a status code, message, headers, and optional body
  • formats that normalized error into the response payload
  • returns Response.json(...) with the resolved status code and headers

The built-in router already wires this up, so if your controller or service throws, you usually do not need to catch the error manually.

ValidationError is special-cased into a field-error payload, while HttpError subclasses such as NotFoundError and ConflictError default to a simple { error: message } body.

Built-in HTTP errors

Morphis exports HttpError plus named subclasses for standard HTTP 4xx and 5xx responses.

The full built-in error catalog is:

StatusError classDefault code
400BadRequestErrorBAD_REQUEST
401UnauthorizedErrorUNAUTHORIZED
402PaymentRequiredErrorPAYMENT_REQUIRED
403ForbiddenErrorFORBIDDEN
404NotFoundErrorNOT_FOUND
405MethodNotAllowedErrorMETHOD_NOT_ALLOWED
406NotAcceptableErrorNOT_ACCEPTABLE
407ProxyAuthenticationRequiredErrorPROXY_AUTHENTICATION_REQUIRED
408RequestTimeoutErrorREQUEST_TIMEOUT
409ConflictErrorCONFLICT
410GoneErrorGONE
411LengthRequiredErrorLENGTH_REQUIRED
412PreconditionFailedErrorPRECONDITION_FAILED
413PayloadTooLargeErrorPAYLOAD_TOO_LARGE
414UriTooLongErrorURI_TOO_LONG
415UnsupportedMediaTypeErrorUNSUPPORTED_MEDIA_TYPE
416RangeNotSatisfiableErrorRANGE_NOT_SATISFIABLE
417ExpectationFailedErrorEXPECTATION_FAILED
418ImATeapotErrorIM_A_TEAPOT
421MisdirectedRequestErrorMISDIRECTED_REQUEST
422UnprocessableEntityErrorUNPROCESSABLE_ENTITY
423LockedErrorLOCKED
424FailedDependencyErrorFAILED_DEPENDENCY
425TooEarlyErrorTOO_EARLY
426UpgradeRequiredErrorUPGRADE_REQUIRED
428PreconditionRequiredErrorPRECONDITION_REQUIRED
429TooManyRequestsErrorTOO_MANY_REQUESTS
431RequestHeaderFieldsTooLargeErrorREQUEST_HEADER_FIELDS_TOO_LARGE
451UnavailableForLegalReasonsErrorUNAVAILABLE_FOR_LEGAL_REASONS
500InternalServerErrorINTERNAL_SERVER_ERROR
501NotImplementedErrorNOT_IMPLEMENTED
502BadGatewayErrorBAD_GATEWAY
503ServiceUnavailableErrorSERVICE_UNAVAILABLE
504GatewayTimeoutErrorGATEWAY_TIMEOUT
505HttpVersionNotSupportedErrorHTTP_VERSION_NOT_SUPPORTED
506VariantAlsoNegotiatesErrorVARIANT_ALSO_NEGOTIATES
507InsufficientStorageErrorINSUFFICIENT_STORAGE
508LoopDetectedErrorLOOP_DETECTED
510NotExtendedErrorNOT_EXTENDED
511NetworkAuthenticationRequiredErrorNETWORK_AUTHENTICATION_REQUIRED

If you need a status without importing a named class, use createHttpError(statusCode, message, options).

Prefer throwing from services

Validation belongs in validators, but business rule failures usually belong in services.

For example:

  • missing route params or malformed body: validator concern
  • post does not exist: service concern
  • post is already published: service concern
  • current user lacks permission to publish: service concern

That split keeps controllers thin and keeps the business workflow in one place.

Service-layer example

This is the shape Morphis is designed for: the controller handles HTTP metadata, and the service throws typed errors for domain failures.

src/services/PostService.ts
import { ConflictError, NotFoundError, Trace } from 'morphis';
import { Post } from '../models/Post';
 
@Trace()
export class PostService {
  async publish(postId: number, userId: number) {
    const post = await Post.findOne({ where: { id: postId } });
 
    if (!post) {
      throw new NotFoundError('Post not found');
    }
 
    if (post.authorId !== userId) {
      throw new ConflictError('You cannot publish another author\'s post');
    }
 
    if (post.status === 'published') {
      throw new ConflictError('Post is already published');
    }
 
    await post.update({ status: 'published' });
    return post;
  }
}
 
export const postService = new PostService();

The controller stays small:

src/controllers/PostController.ts
import { Controller, Post, Request, current } from 'morphis';
import { postService } from '../services/PostService';
 
@Controller('posts')
export class PostController {
  @Post(':id/publish')
  async publish(req: Request) {
    return postService.publish(Number(req.params.id), Number(current.userId));
  }
}

When postService.publish(...) throws, Morphis converts the error into the HTTP response automatically.

{
  "error": "Post not found"
}

Those responses will carry status codes 404 and 409 respectively.

Validation errors

Request validation uses ValidationError under the hood. Its default response body is field-based rather than message-based.

{
  "errors": {
    "title": ["title is required"],
    "status": ["status must be one of: draft, published"]
  }
}

That is why validation should stay in validators instead of being reimplemented manually in services.

Custom responses with HttpError

If the default { error: message } shape is not enough, you can attach headers, details, or a full custom body.

import { HttpError } from 'morphis';
 
throw new HttpError('Feature is temporarily disabled', {
  statusCode: 503,
  code: 'FEATURE_DISABLED',
  headers: { 'Retry-After': '300' },
  body: {
    error: 'Feature is temporarily disabled',
    code: 'FEATURE_DISABLED',
    retryAfterSeconds: 300,
  },
});

Custom error formatter

If you want one consistent API error envelope, set a router-level formatter.

src/routes/api.ts
import { Router } from 'morphis';
 
const router = new Router();
 
router.setErrorFormatter((error, context) => ({
  statusCode: error.statusCode,
  headers: error.headers,
  body: {
    error: error.expose ? error.message : 'Internal server error',
    code: error.code,
    trackId: context.trackId,
  },
}));
 
export default router;

This is especially useful if you want to:

  • hide raw 500 messages in production
  • include a stable machine-readable code
  • attach trackId for support and debugging
  • keep every error response in one JSON shape

Important behavior note

Unexpected plain Error objects are currently normalized as 500 responses using the error’s message by default.

If you do not want internal messages exposed for untyped exceptions, install a custom error formatter and return a generic fallback for server errors.

Last updated on