Skip to content

Exception Guard

A library that helps you handle exceptions in a more elegant way.

Install the Exception Guard

Terminal window
flowcore component add library/exception-guard
Or install the Logger manually

Create a lib/exception-guard.ts file in your project’s lib folder, and add the following code:

/**
* @module ExceptionGuard
* This module provides functionality for handling and guarding against exceptions in an API context.
*/
import {
ApiException,
ApiInternalServerErrorException,
ApiNotFoundException,
ApiUnauthorizedException,
ApiUnprocessableContentException,
} from "@/lib/api-exceptions"
import type { Logger } from "@/lib/logger"
import { Type } from "@sinclair/typebox"
import { Value } from "@sinclair/typebox/value"
import type { StatusMap } from "elysia"
import type { ElysiaCookie } from "elysia/cookies"
import type { HTTPHeaders } from "elysia/types"
/** Represents an error during JSON parsing */
type SafeJsonParseError = [Error, undefined]
/** Represents a successful JSON parse */
type SafeJsonParseSuccess = [undefined, Error]
/** Represents the result of a safe JSON parse operation */
type SafeJsonParseResult = SafeJsonParseError | SafeJsonParseSuccess
/**
* Safely parses a JSON string
* @param obj - The string to parse
* @returns A tuple containing either an Error and undefined, or undefined and the parsed object
*/
function safeParseJson(obj: string): SafeJsonParseResult {
try {
const parsed = JSON.parse(obj)
return [undefined, parsed]
} catch (error) {
return [error as Error, undefined]
}
}
/** Schema for validation errors */
export const ValidationErrorSchema = Type.Object({
on: Type.String(),
errors: Type.Array(
Type.Object({
path: Type.String(),
message: Type.String(),
}),
),
})
/** Possible error codes */
type ErrorCode =
| "VALIDATION"
| "UNKNOWN"
| "NOT_FOUND"
| "PARSE"
| "INTERNAL_SERVER_ERROR"
| "INVALID_COOKIE_SIGNATURE"
| "UNAUTHORIZED"
/** State to be set in response */
type SetState = {
headers: HTTPHeaders
status?: number | keyof StatusMap
redirect?: string
cookie?: Record<string, ElysiaCookie>
}
/** Result of an error specifier */
type ErrorSpecifierResult<T extends ApiException> =
| {
match: true
result: T
}
| {
match: false
result?: never
}
/** Function to specify how to handle a specific type of ApiException */
type ErrorSpecifier<T extends ApiException> = (exception: ApiException) => ErrorSpecifierResult<T>
/** Options for the ExceptionGuard */
interface ExceptionGuardOptions {
logAllErrors?: boolean
}
/**
* Builder class for creating an exception guard
*/
export class ExceptionGuardBuilder {
private logger: Logger = console
private errorSpecifiers: ErrorSpecifier<ApiException>[] = []
private options: ExceptionGuardOptions = {}
/**
* Sets the logger for the exception guard
* @param logger - The logger to use
*/
withLogger(logger: Logger) {
this.logger = logger
return this
}
/**
* Sets the options for the exception guard
* @param options - The options to set
*/
withOptions(options: ExceptionGuardOptions) {
this.options = options
return this
}
/**
* Adds an error specifier to the exception guard
* @param specifier - The error specifier to add
*/
withErrorSpecifier<T extends ApiException>(specifier: ErrorSpecifier<T>) {
this.errorSpecifiers.push(specifier)
return this
}
/**
* Builds and returns the exception guard function
*/
build() {
return (code: ErrorCode, set: SetState, error: Error, request: Request) => {
if (code === "VALIDATION") {
let on: string | undefined
let validationErrors: Record<string, string> | undefined
const [_err, parsedMessage] = safeParseJson(error.message)
if (Value.Check(ValidationErrorSchema, parsedMessage)) {
on = parsedMessage.on
validationErrors = {}
for (const validationError of parsedMessage.errors) {
validationErrors[validationError.path] = validationError.message
}
}
// biome-ignore lint/style/noParameterAssign: intentional
error = new ApiUnprocessableContentException("Unprocessable Content", on, validationErrors)
} else if (code === "NOT_FOUND" && !(error instanceof ApiNotFoundException)) {
// biome-ignore lint/style/noParameterAssign: intentional
error = new ApiNotFoundException("Not Found")
} else if (code === "UNAUTHORIZED" && !(error instanceof ApiUnauthorizedException)) {
// biome-ignore lint/style/noParameterAssign: intentional
error = new ApiUnauthorizedException()
}
const apiError = error instanceof ApiException ? error : new ApiInternalServerErrorException()
const errorLogContext = {
method: request.method,
pathname: new URL(request.url).pathname,
}
if (error !== apiError) {
this.logger.error(error, errorLogContext)
}
set.status = apiError.status
for (const specifier of this.errorSpecifiers) {
const { match, result } = specifier(apiError)
if (match) {
set.status = result.status
if (this.options.logAllErrors) {
this.logger.error(result, errorLogContext)
}
return result
}
}
if (error === apiError && this.options.logAllErrors) {
this.logger.error(error, errorLogContext)
}
return {
status: apiError.status,
code: apiError.code,
message: apiError.status === 500 ? "Internal server error" : apiError.message,
}
}
}
}
/**
* Creates a new ExceptionGuardBuilder with a default error specifier for ApiUnprocessableContentException
* @returns An ExceptionGuardBuilder instance
*/
export function createExceptionGuard() {
return new ExceptionGuardBuilder().withErrorSpecifier<ApiUnprocessableContentException>((apiError) => {
if (apiError instanceof ApiUnprocessableContentException) {
return {
match: true,
result: {
...apiError,
status: apiError.status,
code: apiError.code,
message: apiError.message,
on: apiError.on,
errors: apiError.errors,
},
}
}
return {
match: false,
}
})
}

Usage

To use the Exception Guard, you need to create an instance of the ExceptionGuardBuilder and add your own error specifiers.

server/index.ts
const logger = createLogger("server")
const exceptionGuard = createExceptionGuard()
.withLogger(logger)
.withOptions({ logAllErrors: env.LOG_ALL_ERRORS })
.build()
export const server = new Elysia()
.onError(({ code, set, error, request }) => exceptionGuard(code, set, error, request))
// ...