src/error/error.util.ts
import type {
ErrorData,
ErrorObject,
BackendErrorResponseObject,
Class,
HttpRequestErrorData,
ErrorLike,
} from '..'
import { _jsonParseIfPossible, _stringify, _truncate, _truncateMiddle, isServerSide } from '..'
/**
* Useful to ensure that error in `catch (err) { ... }`
* is indeed an Error (and not e.g `string` or `undefined`).
* 99% of the cases it will be Error already.
* Becomes more useful since TypeScript 4.4 made `err` of type `unknown` by default.
*
* Alternatively, if you're sure it's Error - you can use `_assertIsError(err)`.
*/
export function _anyToError<ERROR_TYPE extends Error = Error>(
o: any,
errorClass: Class<ERROR_TYPE> = Error as any,
errorData?: ErrorData,
): ERROR_TYPE {
let e: ERROR_TYPE
if (o instanceof errorClass) {
e = o
} else {
// If it's an instance of Error, but ErrorClass is something else (e.g AppError) - it'll be "repacked" into AppError
const errorObject = _anyToErrorObject(o)
e = _errorObjectToError(errorObject, errorClass)
}
if (errorData) {
;(e as any).data = {
...(e as any).data,
...errorData,
}
}
return e
}
/**
* Converts "anything" to ErrorObject.
* Detects if it's HttpErrorResponse, HttpErrorObject, ErrorObject, Error, etc..
* If object is Error - Error.message will be used.
* Objects (not Errors) get converted to prettified JSON string (via `_stringify`).
*/
export function _anyToErrorObject<DATA_TYPE extends ErrorData = ErrorData>(
o: any,
errorData?: Partial<DATA_TYPE>,
): ErrorObject<DATA_TYPE> {
let eo: ErrorObject<DATA_TYPE>
if (_isErrorLike(o)) {
eo = _errorLikeToErrorObject(o)
} else {
o = _jsonParseIfPossible(o)
if (_isBackendErrorResponseObject(o)) {
eo = o.error as ErrorObject<DATA_TYPE>
} else if (_isErrorObject(o)) {
eo = o as ErrorObject<DATA_TYPE>
} else if (_isErrorLike(o)) {
eo = _errorLikeToErrorObject(o)
} else {
// Here we are sure it has no `data` property,
// so, fair to return `data: {}` in the end
// Also we're sure it includes no "error name", e.g no `Error: ...`,
// so, fair to include `name: 'Error'`
const message = _stringify(o)
eo = {
name: 'Error',
message,
data: {} as DATA_TYPE, // empty
}
}
}
Object.assign(eo.data, errorData)
return eo
}
export function _errorLikeToErrorObject<DATA_TYPE extends ErrorData = ErrorData>(
e: AppError<DATA_TYPE> | Error | ErrorLike,
): ErrorObject<DATA_TYPE> {
// If it's already an ErrorObject - just return it
// AppError satisfies ErrorObject interface
// Error does not satisfy (lacks `data`)
// UPD: no, we expect a "plain object" here as an output,
// because Error classes sometimes have non-enumerable properties (e.g data)
if (!(e instanceof Error) && _isErrorObject(e)) {
return e as ErrorObject<DATA_TYPE>
}
const obj: ErrorObject<DATA_TYPE> = {
name: e.name,
message: e.message,
data: { ...(e as any).data }, // empty by default
}
if (e.stack) obj.stack = e.stack
if (e.cause) {
obj.cause = _anyToErrorObject(e.cause)
}
return obj
}
export function _errorObjectToError<DATA_TYPE extends ErrorData, ERROR_TYPE extends Error>(
o: ErrorObject<DATA_TYPE>,
errorClass: Class<ERROR_TYPE> = Error as any,
): ERROR_TYPE {
if (o instanceof errorClass) return o
// Here we pass constructor values assuming it's AppError or sub-class of it
// If not - will be checked at the next step
// We cannot check `if (errorClass instanceof AppError)`, only `err instanceof AppError`
const { name, cause } = o
const err = new errorClass(o.message, o.data, { name, cause })
// name: err.name, // cannot be assigned to a readonly property like this
// stack: o.stack, // also readonly e.g in Firefox
if (o.stack) {
Object.defineProperty(err, 'stack', {
value: o.stack,
})
}
if (!(err instanceof AppError)) {
// Following actions are only needed for non-AppError-like errors
Object.defineProperties(err, {
name: {
value: name,
configurable: true,
writable: true,
},
data: {
value: o.data,
writable: true,
configurable: true,
enumerable: false,
},
cause: {
value: cause,
writable: true,
configurable: true,
enumerable: true,
},
})
Object.defineProperty(err.constructor, 'name', {
value: name,
configurable: true,
writable: true,
})
}
return err
}
export interface ErrorSnippetOptions {
/**
* Max length of the error line.
* Snippet may have multiple lines, one original and one per `cause`.
*/
maxLineLength?: number
maxLines?: number
}
// These "common" error classes will not be printed as part of the Error snippet
const commonErrorClasses = new Set([
'Error',
'AppError',
'AssertionError',
'HttpRequestError',
'JoiValidationError',
])
/**
* Provides a short semi-user-friendly error message snippet,
* that would allow to give a hint to the user what went wrong,
* also to developers and CS to distinguish between different errors.
*
* It's not supposed to have full information about the error, just a small extract from it.
*/
export function _errorSnippet(err: any, opt: ErrorSnippetOptions = {}): string {
const { maxLineLength = 60, maxLines = 3 } = opt
const e = _anyToErrorObject(err)
const lines = [errorObjectToSnippet(e)]
let { cause } = e
while (cause && lines.length < maxLines) {
lines.push('Caused by ' + errorObjectToSnippet(cause))
cause = cause.cause // insert DiCaprio Inception meme
}
return lines.map(line => _truncate(line, maxLineLength)).join('\n')
function errorObjectToSnippet(e: ErrorObject): string {
// Return snippet if it was already prepared
if (e.data.snippet) return e.data.snippet
// Code already serves the purpose of the snippet, so we can just return it
if (e.data.code) return e.data.code
return [
!commonErrorClasses.has(e.name) && e.name,
// replace "1+ white space characters" with a single space
e.message.replaceAll(/\s+/gm, ' ').trim(),
]
.filter(Boolean)
.join(': ')
}
}
export function _isBackendErrorResponseObject(o: any): o is BackendErrorResponseObject {
return _isErrorObject(o?.error)
}
export function _isHttpRequestErrorObject(o: any): o is ErrorObject<HttpRequestErrorData> {
return !!o && o.name === 'HttpRequestError' && typeof o.data?.requestUrl === 'string'
}
/**
* Note: any instance of AppError is also automatically an ErrorObject
*/
export function _isErrorObject<DATA_TYPE extends ErrorData = ErrorData>(
o: any,
): o is ErrorObject<DATA_TYPE> {
return (
!!o &&
typeof o === 'object' &&
typeof o.name === 'string' &&
typeof o.message === 'string' &&
typeof o.data === 'object'
)
}
export function _isErrorLike(o: any): o is ErrorLike {
return !!o && typeof o === 'object' && typeof o.name === 'string' && typeof o.message === 'string'
}
/**
* Convenience function to safely add properties to Error's `data` object
* (even if it wasn't previously existing).
* Mutates err.
* Returns err for convenience, so you can re-throw it directly.
*
* @example
*
* try {} catch (err) {
* throw _errorDataAppend(err, {
* httpStatusCode: 401,
* })
* }
*/
export function _errorDataAppend<ERR>(err: ERR, data?: ErrorData): ERR {
if (!data) return err
;(err as any).data = {
...(err as any).data,
...data,
}
return err
}
export interface AppErrorComponents<DATA_TYPE extends ErrorData = ErrorData> {
message: string
name?: string
data?: DATA_TYPE
cause?: any
}
/**
* Extra options for AppError constructor.
*/
export interface AppErrorOptions {
/**
* Overrides Error.name and Error.constructor.name
*/
name?: string
/**
* Sets Error.cause.
* It is transformed with _anyToErrorObject()
*/
cause?: any
}
/**
* Base class for all our (not system) errors.
*
* message - "technical" message. Frontend decides to show it or not.
* data - optional "any" payload.
* data.userFriendly - if present, will be displayed to the User as is.
*
* Based on: https://medium.com/@xpl/javascript-deriving-from-error-properly-8d2f8f315801
*/
export class AppError<DATA_TYPE extends ErrorData = ErrorData> extends Error {
data!: DATA_TYPE
/**
* `cause` here is normalized to be an ErrorObject
*/
override cause?: ErrorObject
/**
* Experimental alternative static constructor.
*/
static of<DATA_TYPE extends ErrorData = ErrorData>(opt: AppErrorComponents): AppError<DATA_TYPE> {
return new AppError<DATA_TYPE>(opt.message, opt.data as DATA_TYPE, {
name: opt.name,
cause: opt.cause,
})
}
constructor(message: string, data = {} as DATA_TYPE, opt: AppErrorOptions = {}) {
super(message)
// Here we default to `this.constructor.name` on Node, but to 'AppError' on the Frontend
// because Frontend tends to minify class names, so `constructor.name` is not reliable
const { name = isServerSide() ? this.constructor.name : 'AppError', cause } = opt
Object.defineProperties(this, {
name: {
value: name,
configurable: true,
writable: true,
},
data: {
value: data,
writable: true,
configurable: true,
enumerable: false,
},
})
if (cause) {
Object.defineProperty(this, 'cause', {
value: _anyToErrorObject(cause),
writable: true,
configurable: true,
enumerable: true, // unlike standard - setting it to true for "visibility"
})
}
// this is to allow changing this.constuctor.name to a non-minified version
Object.defineProperty(this.constructor, 'name', {
value: name,
configurable: true,
writable: true,
})
// todo: check if it's needed at all!
// if (Error.captureStackTrace) {
// Error.captureStackTrace(this, this.constructor)
// } else {
// Object.defineProperty(this, 'stack', {
// value: new Error().stack, // eslint-disable-line unicorn/error-message
// writable: true,
// configurable: true,
// })
// }
}
}
/**
* Error that is thrown when Http Request was made and returned an error.
* Thrown by, for example, Fetcher.
*
* On the Frontend this Error class represents the error when calling the API,
* contains all the necessary request and response information.
*
* On the Backend, similarly, it represents the error when calling some 3rd-party API
* (backend-to-backend call).
* On the Backend it often propagates all the way to the Backend error handler,
* where it would be wrapped in BackendErrorResponseObject.
*
* Please note that `ErrorData.backendResponseStatusCode` is NOT exactly the same as
* `HttpRequestErrorData.responseStatusCode`.
* E.g 3rd-party call may return 401, but our Backend will still wrap it into an 500 error
* (by default).
*/
export class HttpRequestError extends AppError<HttpRequestErrorData> {
constructor(message: string, data: HttpRequestErrorData, opt?: AppErrorOptions) {
if (data.response) {
Object.defineProperty(data, 'response', {
enumerable: false,
})
}
super(message, data, { ...opt, name: 'HttpRequestError' })
}
/**
* Cause is strictly-defined for HttpRequestError,
* so it always has a cause.
* (for dev convenience)
*/
override cause!: ErrorObject
}
export class AssertionError extends AppError {
constructor(message: string, data?: ErrorData) {
super(message, data, { name: 'AssertionError' })
}
}
export interface JsonParseErrorData extends ErrorData {
/**
* Original text that failed to get parsed.
*/
text?: string
}
export class JsonParseError extends AppError<JsonParseErrorData> {
constructor(data: JsonParseErrorData) {
const message = ['Failed to parse', data.text && _truncateMiddle(data.text, 200)]
.filter(Boolean)
.join(': ')
super(message, data, { name: 'JsonParseError' })
}
}
export class TimeoutError extends AppError {
constructor(message: string, data?: ErrorData, opt?: AppErrorOptions) {
super(message, data, { ...opt, name: 'TimeoutError' })
}
}
/**
* It is thrown when Error was expected, but didn't happen
* ("pass" happened instead).
* "Pass" means "no error".
*/
export class UnexpectedPassError extends AppError {
constructor(message?: string) {
super(
message || 'expected error was not thrown',
{},
{
name: 'UnexpectedPassError',
},
)
}
}