├── .prettierrc ├── autocomplete.jpg ├── .gitattributes ├── .npmignore ├── .version-bump.js ├── jest.config.js ├── CONTRIBUTING.md ├── tsconfig.json ├── .changelog.js ├── src ├── index.ts ├── utils.ts ├── error-types │ ├── BaseRegistryError.ts │ ├── __tests__ │ │ ├── BaseRegistryError.test.ts │ │ └── BaseError.test.ts │ └── BaseError.ts ├── __tests__ │ ├── utils.test.ts │ └── ErrorRegistry.test.ts ├── ErrorRegistry.ts └── interfaces.ts ├── LICENSE ├── .gitignore ├── .circleci ├── deploy.sh └── config.yml ├── package.json ├── CHANGELOG.md └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none" 3 | } 4 | -------------------------------------------------------------------------------- /autocomplete.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theogravity/new-error/HEAD/autocomplete.jpg -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Prevent merge conflicts with CHANGELOG.md updates 2 | CHANGELOG.md merge=union 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | .babelrc 3 | test-report.xml 4 | .circleci/ 5 | .idea/ 6 | wallaby.js 7 | __tests__/ 8 | __mocks__/ 9 | .gitattributes 10 | .gitignore 11 | .version-bump.js 12 | tsconfig.json 13 | .changelog.js 14 | .prettierrc 15 | jest.config.js 16 | CONTRIBUTING.md 17 | test-report.xml 18 | coverage/ 19 | api-doc.md 20 | docs/ 21 | typedoc.js 22 | autocomplete.jpg 23 | -------------------------------------------------------------------------------- /.version-bump.js: -------------------------------------------------------------------------------- 1 | // This is an optional configuration file 2 | // If specified, any command line args has priority over the 3 | // values returned in this file. 4 | 5 | // All values are optional. 6 | // Do not use the ES6 export default 7 | // since the file is imported using require() 8 | // See command line options for additional available properties 9 | module.exports = { 10 | strategy: 'cli' 11 | } 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | collectCoverageFrom: [ 4 | 'src/**/*.ts', 5 | '!src/index.ts' 6 | ], 7 | testResultsProcessor: './node_modules/jest-junit-reporter', 8 | testEnvironment: 'node', 9 | testPathIgnorePatterns: [ 10 | '/build', 11 | '/node_modules/' 12 | ], 13 | coverageThreshold: { 14 | global: { 15 | statements: 100, 16 | branches: 100, 17 | functions: 100, 18 | lines: 100 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | - Make sure you have appropriate unit tests that cover your feature 4 | - Make sure coverage % is maintained 5 | 6 | # Changelog 7 | 8 | If a git commit message looks like this: 9 | 10 | ```text 11 | This is my commit subject 12 | 13 | This is my commit body 14 | ``` 15 | 16 | Then the changelog will be stamped in the following fashion on merge: 17 | 18 | ```text 19 | ## - 20 | 21 | **Contributor:** 22 | 23 | - 24 | 25 | 26 | ``` 27 | 28 | # Merging 29 | 30 | Once merged, the CI will auto-publish to npm and the changelog will be updated. 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "resolveJsonModule": true, 5 | "esModuleInterop": true, 6 | "noImplicitAny": false, 7 | "strictNullChecks": false, 8 | "module": "commonjs", 9 | "target": "ES2018", 10 | "sourceMap": true, 11 | "moduleResolution": "node", 12 | "outDir": "build", 13 | "baseUrl": ".", 14 | "declaration": true, 15 | "lib": [ 16 | "ES2018", 17 | "dom" 18 | ], 19 | "paths": { 20 | "*": [ 21 | "node_modules/*" 22 | ] 23 | } 24 | }, 25 | "include": [ 26 | "src/**/*" 27 | ], 28 | "exclude": [ 29 | "src/**/__tests__/*", 30 | "src/**/__mocks__/*", 31 | "src/**/__fixtures__/*" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /.changelog.js: -------------------------------------------------------------------------------- 1 | // This is an optional configuration file 2 | // you can use with changelog-version. 3 | // If specified, any command line args has priority over the 4 | // values returned in this file. 5 | 6 | // All values are optional. 7 | // Do not use the ES6 export default 8 | // since the file is imported using require() 9 | // See command line options for additional available properties 10 | module.exports = { 11 | changelogFile: () => { 12 | return 'CHANGELOG.md' 13 | }, 14 | // ==== Options specific to prepare ==== 15 | newUnreleasedText: `## UNRELEASED 16 | 17 | **Contributor:** {{author.name}} 18 | 19 | - {{{subject}}}{{{body}}}`, 20 | unreleasedTag: () => { 21 | return 'UNRELEASED' 22 | }, 23 | unreleasedTagFormat: '{version} - {date}', 24 | requireUnreleasedEntry: true, 25 | requireUnreleasedEntryFailMsg: `You cannot commit until you've added the release notes to CHANGELOG.md 26 | 27 | See CONTRIBUTING.md for instructions.` 28 | } 29 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { BaseError } from './error-types/BaseError' 2 | import { BaseRegistryError } from './error-types/BaseRegistryError' 3 | import { ErrorRegistry } from './ErrorRegistry' 4 | import { 5 | IBaseError, 6 | SerializedError, 7 | SerializedErrorSafe, 8 | HighLevelError, 9 | LowLevelError, 10 | DeserializeOpts, 11 | GenerateLowLevelErrorOpts, 12 | GenerateHighLevelErrorOpts, 13 | ConvertedType, 14 | ConvertFn, 15 | HLDefs, 16 | LLDefs, 17 | KeyOfStr, 18 | IBaseErrorConfig 19 | } from './interfaces' 20 | 21 | import { generateHighLevelErrors, generateLowLevelErrors } from './utils' 22 | 23 | export { 24 | BaseError, 25 | BaseRegistryError, 26 | ErrorRegistry, 27 | generateHighLevelErrors, 28 | generateLowLevelErrors, 29 | IBaseError, 30 | HighLevelError, 31 | LowLevelError, 32 | SerializedError, 33 | SerializedErrorSafe, 34 | DeserializeOpts, 35 | GenerateLowLevelErrorOpts, 36 | GenerateHighLevelErrorOpts, 37 | ConvertedType, 38 | ConvertFn, 39 | HLDefs, 40 | LLDefs, 41 | KeyOfStr, 42 | IBaseErrorConfig 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Theo Gravity 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | .idea/ 64 | test-report.xml 65 | lib/ 66 | 67 | build/ 68 | coverage/ 69 | -------------------------------------------------------------------------------- /.circleci/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "${CIRCLE_BRANCH}" == "master" ]] 4 | then 5 | set -e 6 | 7 | echo "Raising package version and updating CHANGELOG.md" 8 | 9 | git config --global push.default simple 10 | git config --global user.email "theo@suteki.nu" 11 | git config --global user.name "CircleCI Publisher" 12 | 13 | # Stash any prior changes to prevent merge conflicts 14 | git stash 15 | 16 | # Make sure to get the latest master (if there were any prior commits) 17 | git pull origin master 18 | 19 | # Re-apply the stash 20 | # If there is nothing to apply on the stash, a non-zero exit code happens 21 | # we pipe an empty echo to prevent this 22 | git stash apply || echo "" 23 | 24 | # Version bump package.json, stamp CHANGELOG.md 25 | npm run prepare-publish 26 | 27 | # Changelog is now stamped with the version / time info - add to git 28 | git add CHANGELOG.md 29 | git add package.json 30 | 31 | # Amend the version commit with a ci skip so when we push the commits from the CI 32 | # The CI does not end up recursively building it 33 | 34 | # This gets the last commit log message 35 | PKG_VERSION=`npm run --silent version-bump show-version` 36 | 37 | # Appending [skip ci] to the log message 38 | # Note: --amend does not trigger the pre-commit hooks 39 | git commit -m "${PKG_VERSION} [skip ci]" 40 | 41 | git tag v${PKG_VERSION} 42 | 43 | # Push the commits back to master and assign a versioned release tag 44 | # Had to add --force because the pull was getting rejected each time 45 | git push && git push origin "v${PKG_VERSION}" 46 | 47 | # Publish the package to npm 48 | echo "Publishing package" 49 | npm publish 50 | else 51 | echo "Skipping - branch is not master" 52 | fi 53 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GenerateHighLevelErrorOpts, 3 | GenerateLowLevelErrorOpts, 4 | HighLevelErrorInternal, 5 | HLDefs, 6 | KeyOfStr, 7 | LLDefs, 8 | LowLevelErrorInternal 9 | } from './interfaces' 10 | 11 | /** 12 | * For a given set of error definitions, generate the className and code based on the property name. 13 | * - className is generated as PascalCase. For example 'INTERNAL_ERROR' -> 'InternalError'. 14 | * - code is the name of the property name 15 | */ 16 | export function generateHighLevelErrors< 17 | HLErrors extends HLDefs>, 18 | HLInternal extends HighLevelErrorInternal 19 | > ( 20 | errorDefs: Record>, 21 | opts: GenerateHighLevelErrorOpts = {} 22 | ): HLErrors { 23 | return (Object.keys(errorDefs).reduce< 24 | Record> 25 | >((defs, errId) => { 26 | if (!opts.disableGenerateClassName && !defs[errId].className) { 27 | defs[errId].className = toPascalCase(errId) 28 | } 29 | 30 | if (!opts.disableGenerateCode && !defs[errId].code) { 31 | defs[errId].code = errId 32 | } 33 | 34 | return defs 35 | }, errorDefs) as unknown) as HLErrors 36 | } 37 | 38 | /** 39 | * For a given set of error definitions, generate the subCode based on the property name. 40 | * - subCode is the name of the property name 41 | */ 42 | export function generateLowLevelErrors< 43 | LLErrors extends LLDefs>, 44 | LLInternal extends LowLevelErrorInternal 45 | > ( 46 | errorDefs: Record>, 47 | opts: GenerateLowLevelErrorOpts = {} 48 | ): LLErrors { 49 | return (Object.keys(errorDefs).reduce< 50 | Record> 51 | >((defs, errId) => { 52 | if (!opts.disableGenerateSubCode && !defs[errId].subCode) { 53 | defs[errId].subCode = errId 54 | } 55 | 56 | return defs 57 | }, errorDefs) as unknown) as LLErrors 58 | } 59 | 60 | // https://gist.github.com/jacks0n/e0bfb71a48c64fbbd71e5c6e956b17d7 61 | function toPascalCase (str) { 62 | return str 63 | .match(/[a-z]+/gi) 64 | .map(function (word) { 65 | return word.charAt(0).toUpperCase() + word.substr(1).toLowerCase() 66 | }) 67 | .join('') 68 | } 69 | -------------------------------------------------------------------------------- /src/error-types/BaseRegistryError.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HighLevelError, 3 | IBaseErrorConfig, 4 | LowLevelErrorInternal, 5 | IBaseError, 6 | SerializedError, 7 | DeserializeOpts 8 | } from '../interfaces' 9 | 10 | import { BaseError } from './BaseError' 11 | 12 | /** 13 | * Improved error class generated by the ErrorRegistry. 14 | **/ 15 | export class BaseRegistryError extends BaseError { 16 | constructor ( 17 | highLevelErrorDef: HighLevelError, 18 | lowLevelErrorDef: LowLevelErrorInternal, 19 | config: IBaseErrorConfig = {} 20 | ) { 21 | if (typeof highLevelErrorDef.onConvert === 'function') { 22 | config.onConvert = highLevelErrorDef.onConvert 23 | } 24 | 25 | if (typeof lowLevelErrorDef.onConvert === 'function') { 26 | config.onConvert = lowLevelErrorDef.onConvert 27 | } 28 | 29 | super(lowLevelErrorDef.message, config) 30 | 31 | this.withErrorCode(highLevelErrorDef.code) 32 | 33 | if (highLevelErrorDef.statusCode) { 34 | this.withStatusCode(highLevelErrorDef.statusCode) 35 | } 36 | 37 | if (highLevelErrorDef.logLevel) { 38 | this.withLogLevel(highLevelErrorDef.logLevel) 39 | } 40 | 41 | if (lowLevelErrorDef.statusCode) { 42 | this.withStatusCode(lowLevelErrorDef.statusCode) 43 | } 44 | 45 | if (lowLevelErrorDef.type) { 46 | this.withErrorType(lowLevelErrorDef.type) 47 | } 48 | 49 | if (lowLevelErrorDef.subCode) { 50 | this.withErrorSubCode(lowLevelErrorDef.subCode) 51 | } 52 | 53 | if (lowLevelErrorDef.logLevel) { 54 | this.withLogLevel(lowLevelErrorDef.logLevel) 55 | } 56 | } 57 | 58 | /** 59 | * Deserializes an error into an instance 60 | * @param {string} data JSON.parse()'d error object from 61 | * BaseError#toJSON() or BaseError#toJSONSafe() 62 | * @param {DeserializeOpts} [opts] Deserialization options 63 | */ 64 | static fromJSON ( 65 | data: Partial, 66 | opts?: T 67 | ): IBaseError { 68 | if (!opts) { 69 | opts = {} as T 70 | } 71 | 72 | if (typeof data !== 'object') { 73 | throw new Error('fromJSON(): Data is not an object.') 74 | } 75 | 76 | const err = new this( 77 | { 78 | code: data.code 79 | }, 80 | { 81 | message: data.message 82 | } 83 | ) 84 | 85 | this.copyDeserializationData(err, data, opts) 86 | 87 | return err 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { generateHighLevelErrors, generateLowLevelErrors } from '../utils' 4 | 5 | const errors = { 6 | INTERNAL_SERVER_ERROR: {}, 7 | AUTH_ERROR: { 8 | className: 'AuthError', 9 | code: 'AUTH_ERR' 10 | } 11 | } 12 | 13 | const errorCodes = { 14 | DATABASE_FAILURE: { 15 | statusCode: 500, 16 | message: 'There is an issue with the database' 17 | }, 18 | ANOTHER_FAILURE: { 19 | subCode: 'ANOTHER_FAILURE', 20 | message: 'Another failure' 21 | } 22 | } 23 | 24 | describe('utils', () => { 25 | describe('generateHighLevelErrors', () => { 26 | it('should generate the codes and classNames', () => { 27 | expect(generateHighLevelErrors(errors)).toMatchObject({ 28 | INTERNAL_SERVER_ERROR: { 29 | className: 'InternalServerError', 30 | code: 'INTERNAL_SERVER_ERROR' 31 | }, 32 | AUTH_ERROR: { 33 | className: 'AuthError', 34 | code: 'AUTH_ERR' 35 | } 36 | }) 37 | }) 38 | 39 | it('should not generate className', () => { 40 | expect( 41 | generateHighLevelErrors(errors, { disableGenerateClassName: true }) 42 | ).toMatchObject({ 43 | INTERNAL_SERVER_ERROR: { 44 | code: 'INTERNAL_SERVER_ERROR' 45 | }, 46 | AUTH_ERROR: { 47 | className: 'AuthError', 48 | code: 'AUTH_ERR' 49 | } 50 | }) 51 | }) 52 | 53 | it('should not generate code', () => { 54 | expect( 55 | generateHighLevelErrors(errors, { disableGenerateCode: true }) 56 | ).toMatchObject({ 57 | INTERNAL_SERVER_ERROR: { 58 | className: 'InternalServerError' 59 | }, 60 | AUTH_ERROR: { 61 | className: 'AuthError', 62 | code: 'AUTH_ERR' 63 | } 64 | }) 65 | }) 66 | }) 67 | 68 | describe('generateLowLevelErrors', () => { 69 | it('should generate the subcodes', () => { 70 | expect(generateLowLevelErrors(errorCodes)).toMatchObject({ 71 | DATABASE_FAILURE: { 72 | subCode: 'DATABASE_FAILURE', 73 | statusCode: 500, 74 | message: 'There is an issue with the database' 75 | }, 76 | ANOTHER_FAILURE: { 77 | subCode: 'ANOTHER_FAILURE', 78 | message: 'Another failure' 79 | } 80 | }) 81 | }) 82 | 83 | it('should not generate the subcodes', () => { 84 | expect( 85 | generateLowLevelErrors(errorCodes, { disableGenerateSubCode: true }) 86 | ).toMatchObject({ 87 | DATABASE_FAILURE: { 88 | statusCode: 500, 89 | message: 'There is an issue with the database' 90 | }, 91 | ANOTHER_FAILURE: { 92 | subCode: 'ANOTHER_FAILURE', 93 | message: 'Another failure' 94 | } 95 | }) 96 | }) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2.1 6 | 7 | npm-login: &npm-login 8 | # NPM_TOKEN is manually defined in CircleCI 9 | # project settings > Build settings > Environment variables 10 | # Add the NPM_TOKEN name and the value is your npm token 11 | # Get your npm token via npm token create 12 | # https://docs.npmjs.com/cli/token 13 | run: 14 | name: Create .npmrc 15 | command: | 16 | echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> ~/.npmrc 17 | 18 | orbs: 19 | node: circleci/node@1.1.6 20 | 21 | jobs: 22 | test: 23 | executor: 24 | name: node/default 25 | tag: '14' 26 | working_directory: ~/repo 27 | steps: 28 | # Check out the git repo to the working directory 29 | - checkout 30 | - add_ssh_keys: 31 | fingerprints: 32 | # You need to add a deploy key with write permission in order for the CI to commit changes 33 | # back to the repo 34 | # https://circleci.com/docs/2.0/gh-bb-integration/#adding-readwrite-deployment-keys-to-github-or-bitbucket 35 | - "78:3a:17:4e:76:3a:e8:43:c5:7d:6c:a0:aa:17:07:2a" 36 | # Download and cache dependencies so subsequent builds run faster 37 | - restore_cache: 38 | keys: 39 | - dependencies-{{ checksum "package-lock.json" }} 40 | # fallback to using the latest cache if no exact match is found 41 | - dependencies- 42 | - run: 43 | name: Install deps 44 | command: | 45 | npm i 46 | - save_cache: 47 | paths: 48 | - node_modules 49 | key: dependencies-{{ checksum "package-lock.json" }} 50 | - run: 51 | name: Run tests 52 | command: | 53 | npm run test:ci 54 | - run: 55 | name: Build the libraries 56 | command: | 57 | npm run build 58 | publish: 59 | executor: 60 | name: node/default 61 | tag: '14' 62 | working_directory: ~/repo 63 | steps: 64 | # Check out the git repo to the working directory 65 | - checkout 66 | # Create the .npmrc file so npm can auth for publishing 67 | - *npm-login 68 | - add_ssh_keys: 69 | fingerprints: 70 | # You need to add a deploy key with write permission in order for the CI to commit changes 71 | # back to the repo 72 | # https://circleci.com/docs/2.0/gh-bb-integration/#adding-readwrite-deployment-keys-to-github-or-bitbucket 73 | - "78:3a:17:4e:76:3a:e8:43:c5:7d:6c:a0:aa:17:07:2a" 74 | # Download and cache dependencies so subsequent builds run faster 75 | - restore_cache: 76 | keys: 77 | - dependencies-{{ checksum "package-lock.json" }} 78 | # fallback to using the latest cache if no exact match is found 79 | - dependencies- 80 | - run: 81 | name: Install deps 82 | command: | 83 | npm i 84 | - save_cache: 85 | paths: 86 | - node_modules 87 | key: dependencies-{{ checksum "package-lock.json" }} 88 | - run: 89 | name: Build the libraries 90 | command: | 91 | npm run build 92 | - run: 93 | name: npm publish (master only) 94 | command: | 95 | .circleci/deploy.sh 96 | 97 | workflows: 98 | version: 2 99 | build: 100 | jobs: 101 | - test 102 | - publish: 103 | filters: 104 | branches: 105 | only: master 106 | requires: 107 | - test 108 | context: npm-build 109 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "new-error", 3 | "version": "2.2.0", 4 | "description": "A production-grade error creation and serialization library designed for Typescript", 5 | "main": "build/index.js", 6 | "types": "build/index.d.ts", 7 | "scripts": { 8 | "build": "npm run build:clean && npm run compile", 9 | "build:clean": "rm -rf build/*", 10 | "compile": "tsc", 11 | "debug": "ts-node-dev --inspect -- src/index.ts", 12 | "debug:break": "ts-node-dev --inspect-brk -- src/index.ts", 13 | "test": "jest", 14 | "test:ci": "jest --ci --coverage", 15 | "test:debug": "node --inspect-brk node_modules/.bin/jest", 16 | "test:watch": "jest --watch", 17 | "test:coverage:watch": "jest --coverage --watch", 18 | "toc": "toc-md README.md README.md", 19 | "add-readme": "git add README.md", 20 | "lint-staged": "lint-staged", 21 | "prepare-publish": "npm run changelog:prepare && version-bump && npm run changelog:release && npm run changelog:stamp", 22 | "version-bump": "version-bump", 23 | "changelog:help": "changelog-version", 24 | "changelog:verify": "changelog-version verify", 25 | "changelog:prepare": "changelog-version prepare", 26 | "changelog:stamp": "git-commit-stamper parse CHANGELOG.md", 27 | "changelog:release": "changelog-version release", 28 | "lint": "prettier-standard src/**/*.ts && standardx src/**/*.ts", 29 | "ts-node-dev": "ts-node-dev" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/theogravity/new-error.git" 34 | }, 35 | "author": "Theo Gravity ", 36 | "license": "MIT", 37 | "keywords": [ 38 | "error", 39 | "throw", 40 | "custom", 41 | "generate", 42 | "new", 43 | "extends", 44 | "factory", 45 | "subclass", 46 | "inherit", 47 | "extension", 48 | "create", 49 | "typescript", 50 | "serialize", 51 | "collection", 52 | "stack", 53 | "trace", 54 | "err", 55 | "log", 56 | "logging" 57 | ], 58 | "bugs": { 59 | "url": "https://github.com/theogravity/new-error/issues" 60 | }, 61 | "homepage": "https://github.com/theogravity/new-error#readme", 62 | "dependencies": { 63 | "es6-error": "^4.1.1", 64 | "sprintf-js": "^1.1.2" 65 | }, 66 | "devDependencies": { 67 | "@theo.gravity/changelog-version": "2.1.11", 68 | "@theo.gravity/version-bump": "2.0.14", 69 | "@types/jest": "29.5.2", 70 | "@types/node": "^20.3.1", 71 | "@typescript-eslint/eslint-plugin": "^5.33.0", 72 | "@typescript-eslint/parser": "^5.33.0", 73 | "eslint": "8.21.0", 74 | "git-commit-stamper": "^1.0.10", 75 | "jest": "^29.5.0", 76 | "jest-cli": "29.5.0", 77 | "jest-junit-reporter": "1.1.0", 78 | "lint-staged": "13.0.3", 79 | "pre-commit": "1.2.2", 80 | "prettier-standard": "^16.4.1", 81 | "standardx": "^7.0.0", 82 | "toc-md-alt": "^0.4.6", 83 | "ts-jest": "29.1.0", 84 | "ts-node": "10.9.1", 85 | "ts-node-dev": "^2.0.0", 86 | "typescript": "5.1.3" 87 | }, 88 | "eslintConfig": { 89 | "parserOptions": { 90 | "ecmaVersion": 6, 91 | "sourceType": "module", 92 | "ecmaFeatures": { 93 | "modules": true 94 | } 95 | }, 96 | "parser": "@typescript-eslint/parser", 97 | "plugins": [ 98 | "@typescript-eslint/eslint-plugin" 99 | ], 100 | "rules": { 101 | "@typescript-eslint/no-unused-vars": [ 102 | 2, 103 | { 104 | "args": "none" 105 | } 106 | ] 107 | } 108 | }, 109 | "lint-staged": { 110 | "src/**/*.ts": [ 111 | "prettier-standard", 112 | "git add" 113 | ] 114 | }, 115 | "pre-commit": [ 116 | "toc", 117 | "lint-staged", 118 | "test:ci", 119 | "build" 120 | ] 121 | } 122 | -------------------------------------------------------------------------------- /src/error-types/__tests__/BaseRegistryError.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { BaseRegistryError } from '../BaseRegistryError' 3 | 4 | describe('BaseRegistryError', () => { 5 | it('should create an instance', () => { 6 | const err = new BaseRegistryError( 7 | { 8 | code: 'TEST_ERR', 9 | statusCode: 300 10 | }, 11 | { 12 | type: 'LOW_LEVEL_TYPE', 13 | statusCode: 400, 14 | subCode: 'SUB_CODE_ERR', 15 | message: 'This is a test error' 16 | } 17 | ) 18 | 19 | err.withErrorId('err-id') 20 | err.withRequestId('req-id') 21 | 22 | expect(err.toJSON()).toEqual( 23 | expect.objectContaining({ 24 | errId: 'err-id', 25 | code: 'TEST_ERR', 26 | subCode: 'SUB_CODE_ERR', 27 | statusCode: 400, 28 | message: 'This is a test error' 29 | }) 30 | ) 31 | 32 | expect(err.getErrorId()).toBe('err-id') 33 | expect(err.getRequestId()).toBe('req-id') 34 | expect(err.getErrorName()).toBe('BaseRegistryError') 35 | expect(err.getCode()).toBe('TEST_ERR') 36 | expect(err.getSubCode()).toBe('SUB_CODE_ERR') 37 | expect(err.getStatusCode()).toBe(400) 38 | expect(err.getErrorType()).toBe('LOW_LEVEL_TYPE') 39 | expect(err.stack).toBeDefined() 40 | }) 41 | 42 | it('should create an instance with config options', () => { 43 | const err = new BaseRegistryError( 44 | { 45 | code: 'TEST_ERR', 46 | statusCode: 300 47 | }, 48 | { 49 | type: 'LOW_LEVEL_TYPE', 50 | statusCode: 400, 51 | subCode: 'SUB_CODE_ERR', 52 | message: 'This is a test error' 53 | }, 54 | { 55 | toJSONFieldsToOmit: ['errId'], 56 | toJSONSafeFieldsToOmit: ['code'] 57 | } 58 | ) 59 | 60 | err.withErrorId('err-id') 61 | 62 | expect(err.toJSON().errId).not.toBeDefined() 63 | expect(err.toJSONSafe().code).not.toBeDefined() 64 | }) 65 | 66 | describe('error codes', () => { 67 | it('should use the default high level error code', () => { 68 | const err = new BaseRegistryError( 69 | { 70 | code: 'TEST_ERR', 71 | statusCode: 300 72 | }, 73 | { 74 | message: 'This is a test error' 75 | } 76 | ) 77 | 78 | expect(err.toJSON()).toEqual( 79 | expect.objectContaining({ 80 | statusCode: 300 81 | }) 82 | ) 83 | 84 | expect(err.stack).toBeDefined() 85 | }) 86 | 87 | it('should use the low level error code', () => { 88 | const err = new BaseRegistryError( 89 | { 90 | code: 'TEST_ERR' 91 | }, 92 | { 93 | message: 'This is a test error', 94 | statusCode: 500 95 | } 96 | ) 97 | 98 | expect(err.toJSON()).toEqual( 99 | expect.objectContaining({ 100 | statusCode: 500 101 | }) 102 | ) 103 | 104 | expect(err.stack).toBeDefined() 105 | }) 106 | }) 107 | 108 | describe('log levels', () => { 109 | it('should use the default high level log level', () => { 110 | const err = new BaseRegistryError( 111 | { 112 | code: 'TEST_ERR', 113 | logLevel: 'debug' 114 | }, 115 | { 116 | message: 'This is a test error' 117 | } 118 | ) 119 | 120 | expect(err.getLogLevel()).toBe('debug') 121 | }) 122 | 123 | it('should use the low level log level', () => { 124 | const err = new BaseRegistryError( 125 | { 126 | code: 'TEST_ERR', 127 | logLevel: 'debug' 128 | }, 129 | { 130 | message: 'This is a test error', 131 | logLevel: 'trace' 132 | } 133 | ) 134 | 135 | expect(err.getLogLevel()).toBe('trace') 136 | }) 137 | 138 | it('should use the low level log level 2', () => { 139 | const err = new BaseRegistryError( 140 | { 141 | code: 'TEST_ERR' 142 | }, 143 | { 144 | message: 'This is a test error', 145 | logLevel: 'trace' 146 | } 147 | ) 148 | 149 | expect(err.getLogLevel()).toBe('trace') 150 | }) 151 | }) 152 | 153 | describe('Deserialization', () => { 154 | it('throws if the data is not an object', () => { 155 | // @ts-ignore 156 | expect(() => BaseRegistryError.fromJSON('')).toThrowError() 157 | }) 158 | 159 | it('should deserialize error data', () => { 160 | const data = { 161 | errId: 'err-123', 162 | code: 'ERR_INT_500', 163 | subCode: 'DB_0001', 164 | message: 'test message', 165 | meta: { safe: 'test454', test: 'test123' }, 166 | name: 'BaseError', 167 | statusCode: 500, 168 | causedBy: 'test' 169 | } 170 | 171 | const err2 = BaseRegistryError.fromJSON(data, { 172 | safeMetadataFields: { 173 | safe: true 174 | } 175 | }) 176 | 177 | expect(err2.getSafeMetadata()).toEqual({ 178 | safe: 'test454' 179 | }) 180 | 181 | expect(err2.toJSON()).toEqual( 182 | expect.objectContaining({ 183 | errId: 'err-123', 184 | code: 'ERR_INT_500', 185 | subCode: 'DB_0001', 186 | message: 'test message', 187 | meta: { safe: 'test454', test: 'test123' }, 188 | name: 'BaseRegistryError', 189 | statusCode: 500, 190 | causedBy: 'test' 191 | }) 192 | ) 193 | }) 194 | }) 195 | }) 196 | -------------------------------------------------------------------------------- /src/ErrorRegistry.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeserializeOpts, 3 | HighLevelErrorInternal, 4 | HLDefs, 5 | IBaseError, 6 | IErrorRegistryConfig, 7 | IErrorRegistryContextConfig, 8 | KeyOfStr, 9 | LLDefs, 10 | LowLevelErrorInternal, 11 | SerializedError 12 | } from './interfaces' 13 | 14 | import { BaseRegistryError } from './error-types/BaseRegistryError' 15 | import { BaseError } from './error-types/BaseError' 16 | 17 | /** 18 | * Contains the definitions for High and Low Level Errors and 19 | * generates custom errors from those definitions. 20 | */ 21 | export class ErrorRegistry< 22 | HLErrors extends HLDefs>, 23 | LLErrors extends LLDefs> 24 | > { 25 | /** 26 | * High level error definitions 27 | */ 28 | protected highLevelErrors: HLErrors 29 | 30 | /** 31 | * A map of high level names to class name 32 | */ 33 | protected classNameHighLevelNameMap: Record, string> 34 | 35 | /** 36 | * Cached high level error classes 37 | */ 38 | protected highLevelErrorClasses: Record< 39 | KeyOfStr, 40 | typeof BaseRegistryError 41 | > 42 | 43 | /** 44 | * Low level error definitions 45 | */ 46 | protected lowLevelErrors: LLErrors 47 | 48 | /** 49 | * Error registry configuration 50 | */ 51 | protected _config: IErrorRegistryConfig 52 | 53 | /** 54 | * Error metadata to always include in an error 55 | */ 56 | protected _newErrorContext: IErrorRegistryContextConfig | null 57 | 58 | constructor ( 59 | highLvErrors: HLErrors, 60 | lowLvErrors: LLErrors, 61 | config: IErrorRegistryConfig = {} 62 | ) { 63 | this.highLevelErrors = highLvErrors 64 | this.lowLevelErrors = {} as any 65 | this.classNameHighLevelNameMap = {} as any 66 | this.highLevelErrorClasses = {} as any 67 | this._config = config 68 | this._newErrorContext = null 69 | 70 | Object.keys(highLvErrors).forEach(name => { 71 | this.classNameHighLevelNameMap[highLvErrors[name].className] = name 72 | }) 73 | 74 | // populate the lowLevelErrors dictionary 75 | Object.keys(lowLvErrors).forEach(type => { 76 | const errCode = lowLvErrors[type] as LowLevelErrorInternal 77 | errCode.type = type 78 | this.lowLevelErrors[type] = errCode 79 | }) 80 | } 81 | 82 | /** 83 | * Gets the definition of a High Level Error 84 | * @param {string} highLvErrName 85 | */ 86 | protected getHighLevelError ( 87 | highLvErrName: KeyOfStr 88 | ): HighLevelErrorInternal { 89 | return this.highLevelErrors[highLvErrName] 90 | } 91 | 92 | /** 93 | * Gets the definition of a Low Level Error 94 | * @param {string} lowLvErrName 95 | */ 96 | protected getLowLevelError ( 97 | lowLvErrName: KeyOfStr 98 | ): LowLevelErrorInternal { 99 | return this.lowLevelErrors[lowLvErrName] 100 | } 101 | 102 | /** 103 | * Gets the class definition of a High Level Error 104 | * @param highLvErrName 105 | */ 106 | getClass (highLvErrName: KeyOfStr): typeof BaseRegistryError { 107 | const highLevelDef = this.getHighLevelError(highLvErrName) 108 | 109 | if (!highLevelDef) { 110 | throw new Error(`High level error not defined: ${highLvErrName}`) 111 | } 112 | 113 | // Create class definition of the highLevelError and cache 114 | if (!this.highLevelErrorClasses[highLvErrName]) { 115 | // https://stackoverflow.com/questions/33605775/es6-dynamic-class-names 116 | const C = class extends BaseRegistryError {} 117 | Object.defineProperty(C, 'name', { value: highLevelDef.className }) 118 | 119 | this.highLevelErrorClasses[highLvErrName] = C 120 | } 121 | 122 | return this.highLevelErrorClasses[highLvErrName] 123 | } 124 | 125 | /** 126 | * Compares an instance of an object to a specified High Level Error 127 | */ 128 | instanceOf (a: any, highLvErrName: KeyOfStr) { 129 | return a instanceof this.getClass(highLvErrName) 130 | } 131 | 132 | /** 133 | * Creates an instance of a High Level Error, without a Low Level Error 134 | * attached to it. 135 | * @param {string} highLvErrName 136 | * @param {string} [message] Error message. If not defined and the high level error has the 137 | * message property defined, will use that instead. If the high level error message is not defined, 138 | * then will use the high level error id instead for the message. 139 | */ 140 | newBareError ( 141 | highLvErrName: KeyOfStr, 142 | message?: string 143 | ): BaseRegistryError { 144 | const hlErrDef = this.highLevelErrors[highLvErrName] 145 | 146 | if (!message && hlErrDef.message) { 147 | message = hlErrDef.message 148 | } else if (!message) { 149 | message = hlErrDef.code.toString() 150 | } 151 | 152 | const C = this.getClass(highLvErrName) 153 | const err = new C( 154 | this.getHighLevelError(highLvErrName), 155 | { 156 | message 157 | }, 158 | this._config.baseErrorConfig 159 | ) 160 | 161 | if (typeof this._config.onCreateError === 'function') { 162 | this._config.onCreateError(err) 163 | } 164 | 165 | this.reformatTrace(err) 166 | 167 | return this.assignErrContext(err) 168 | } 169 | 170 | /** 171 | * Creates an instance of a High Level Error, with a Low Level Error 172 | * attached to it. 173 | * @param {string} highLvErrName 174 | * @param {string} lowLvErrName 175 | */ 176 | newError ( 177 | highLvErrName: KeyOfStr, 178 | lowLvErrName: KeyOfStr 179 | ): BaseRegistryError { 180 | if (!this.lowLevelErrors[lowLvErrName]) { 181 | throw new Error(`Low level error not defined: ${lowLvErrName}`) 182 | } 183 | 184 | const C = this.getClass(highLvErrName) 185 | const err = new C( 186 | this.getHighLevelError(highLvErrName), 187 | this.getLowLevelError(lowLvErrName) as LowLevelErrorInternal, 188 | this._config.baseErrorConfig 189 | ) 190 | 191 | if (typeof this._config.onCreateError === 'function') { 192 | this._config.onCreateError(err) 193 | } 194 | 195 | this.reformatTrace(err) 196 | 197 | return this.assignErrContext(err) 198 | } 199 | 200 | /** 201 | * Updates the stack trace to remove the error registry entry: 202 | * "at ErrorRegistry.newError" and related entries 203 | */ 204 | private reformatTrace (err: BaseRegistryError): void { 205 | const stack = err.stack.split('\n') 206 | stack.splice(1, 1) 207 | err.stack = stack.join('\n') 208 | } 209 | 210 | /** 211 | * If a new error context is defined, then set the values 212 | */ 213 | private assignErrContext (err: BaseError) { 214 | if (!this._newErrorContext) { 215 | return err 216 | } 217 | 218 | if (this._newErrorContext.metadata) { 219 | err.withMetadata(this._newErrorContext.metadata) 220 | } 221 | 222 | if (this._newErrorContext.safeMetadata) { 223 | err.withSafeMetadata(this._newErrorContext.safeMetadata) 224 | } 225 | 226 | return err 227 | } 228 | 229 | /** 230 | * Deserializes data into an error 231 | * @param {string} data JSON.parse()'d error object from 232 | * BaseError#toJSON() or BaseError#toJSONSafe() 233 | * @param {DeserializeOpts} [opts] Deserialization options 234 | */ 235 | fromJSON< 236 | T extends IBaseError = IBaseError, 237 | U extends DeserializeOpts = DeserializeOpts 238 | > (data: Partial, opts?: U): T { 239 | if (typeof data !== 'object') { 240 | throw new Error(`fromJSON(): Data is not an object.`) 241 | } 242 | 243 | // data.name is the class name - we need to resolve it to the name of the high level class definition 244 | const errorName = this.classNameHighLevelNameMap[data.name] 245 | 246 | // use the lookup results to see if we can get the class definition of the high level error 247 | const highLevelDef = this.getHighLevelError(errorName as KeyOfStr) 248 | 249 | let err = null 250 | 251 | // Can deserialize into an custom error instance class 252 | if (highLevelDef) { 253 | // get the class for the error type 254 | const C = this.getClass(errorName as KeyOfStr) 255 | err = C.fromJSON(data, opts) 256 | } else { 257 | err = BaseError.fromJSON(data, opts) 258 | } 259 | 260 | return err 261 | } 262 | 263 | /** 264 | * Creates a new error registry instance that will include / set specific data 265 | * for each new error created. 266 | * 267 | * Memory overhead should be trivial as the internal *references* of the existing 268 | * error registry instance properties is copied over to the new instance. 269 | */ 270 | withContext ( 271 | context: IErrorRegistryContextConfig 272 | ): ErrorRegistry { 273 | const registry = new ErrorRegistry({}, {}) 274 | 275 | Object.keys(this).forEach(property => { 276 | // copy over references vs creating completely new entries 277 | registry[property] = this[property] 278 | }) 279 | 280 | registry._newErrorContext = context 281 | 282 | return registry as ErrorRegistry 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.2.0 - Wed Jun 14 2023 12:01:40 2 | 3 | **Contributor:** Theo Gravity 4 | 5 | - Add `withRequestId()` / `getRequestId()` to `BaseError` 6 | 7 | ## 2.1.15 - Thu Nov 10 2022 23:14:01 8 | 9 | **Contributor:** dependabot[bot] 10 | 11 | - Bump minimatch from 3.0.4 to 3.1.2 (#25) 12 | 13 | ## 2.1.14 - Tue Aug 09 2022 19:41:09 14 | 15 | **Contributor:** Theo Gravity 16 | 17 | - Update dev deps, export IBaseErrorConfig interface (#24) 18 | 19 | This includes the IBaseErrorConfig interface as a mainline export 20 | 21 | ## 2.1.13 - Fri Jan 28 2022 22:41:55 22 | 23 | **Contributor:** Theo Gravity 24 | 25 | - Fix onConvert() failing in certain situations (#23) 26 | 27 | ## 2.1.12 - Fri Oct 29 2021 19:39:32 28 | 29 | **Contributor:** dependabot[bot] 30 | 31 | - Bump tmpl from 1.0.4 to 1.0.5 (#22) 32 | 33 | Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5. 34 | - [Release notes](https://github.com/daaku/nodejs-tmpl/releases) 35 | - [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5) 36 | 37 | --- 38 | updated-dependencies: 39 | - dependency-name: tmpl 40 | dependency-type: indirect 41 | ... 42 | 43 | Signed-off-by: dependabot[bot] 44 | 45 | Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 46 | 47 | ## 2.1.11 - Fri Aug 13 2021 03:59:06 48 | 49 | **Contributor:** Theo Gravity 50 | 51 | - [minor] Add config option to append attached error message to main error message (#21) 52 | 53 | Added a `BaseError` config option, `appendWithErrorMessageFormat`, which 54 | will append the attached error message to the main error message. Useful 55 | for testing frameworks like Jest, which will not print the attached message. 56 | 57 | ## 2.1.10 - Fri May 28 2021 00:34:25 58 | 59 | **Contributor:** dependabot[bot] 60 | 61 | - Bump browserslist from 4.16.3 to 4.16.6 (#17) 62 | 63 | Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.16.3 to 4.16.6. 64 | - [Release notes](https://github.com/browserslist/browserslist/releases) 65 | - [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md) 66 | - [Commits](https://github.com/browserslist/browserslist/compare/4.16.3...4.16.6) 67 | 68 | Signed-off-by: dependabot[bot] 69 | 70 | Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 71 | 72 | ## 2.1.9 - Thu May 13 2021 03:11:48 73 | 74 | **Contributor:** dependabot[bot] 75 | 76 | - Bump hosted-git-info from 2.8.8 to 2.8.9 (#16) 77 | 78 | ## 2.1.8 - Sun May 09 2021 20:32:52 79 | 80 | **Contributor:** dependabot[bot] 81 | 82 | - Bump lodash from 4.17.20 to 4.17.21 (#15) 83 | 84 | Bumps [lodash](https://github.com/lodash/lodash) from 4.17.20 to 4.17.21. 85 | - [Release notes](https://github.com/lodash/lodash/releases) 86 | - [Commits](https://github.com/lodash/lodash/compare/4.17.20...4.17.21) 87 | 88 | Signed-off-by: dependabot[bot] 89 | 90 | Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 91 | Co-authored-by: Theo Gravity 92 | 93 | ## 2.1.7 - Sun May 09 2021 20:30:20 94 | 95 | **Contributor:** dependabot[bot] 96 | 97 | - Bump handlebars from 4.7.6 to 4.7.7 (#14) 98 | 99 | Bumps [handlebars](https://github.com/wycats/handlebars.js) from 4.7.6 to 4.7.7. 100 | - [Release notes](https://github.com/wycats/handlebars.js/releases) 101 | - [Changelog](https://github.com/handlebars-lang/handlebars.js/blob/master/release-notes.md) 102 | - [Commits](https://github.com/wycats/handlebars.js/compare/v4.7.6...v4.7.7) 103 | 104 | Signed-off-by: dependabot[bot] 105 | 106 | Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 107 | 108 | ## 2.1.6 - Fri Apr 02 2021 20:34:42 109 | 110 | **Contributor:** dependabot[bot] 111 | 112 | - Bump y18n from 4.0.0 to 4.0.1 (#13) 113 | 114 | ## 2.1.5 - Sat Mar 20 2021 05:11:00 115 | 116 | **Contributor:** Theo Gravity 117 | 118 | - Fix `BaseError#setConfig()` not being chainable 119 | 120 | It was returning void rather than the instance back. This fixes that. 121 | 122 | ## 2.1.4 - Fri Mar 19 2021 04:22:07 123 | 124 | **Contributor:** Theo Gravity 125 | 126 | - Fix typescript intelligence on `ErrorRegistry#withContext()` 127 | 128 | The return type of `withContext()` was being inferred as `any` by Typescript. This now appropriately set to return `ErrorRegistry`. 129 | 130 | ## 2.1.3 - Fri Mar 19 2021 02:55:37 131 | 132 | **Contributor:** Theo Gravity 133 | 134 | - Update readme 135 | 136 | ## 2.1.2 - Fri Mar 19 2021 02:53:03 137 | 138 | **Contributor:** Theo Gravity 139 | 140 | - Update readme 141 | 142 | ## 2.1.1 - Fri Mar 19 2021 02:47:52 143 | 144 | **Contributor:** Theo Gravity 145 | 146 | - Add the ability to create child error registries 147 | 148 | You can now create child registries with context via `ErrorRegistry#withContext()` that will create 149 | errors with the predefined context. 150 | 151 | The use-case is if your code block throws many errors, and you want to use the same metadata without 152 | setting it each time, so code is not duplicated. 153 | 154 | See readme for more information. 155 | 156 | ## 2.0.2 - Sun Mar 14 2021 22:12:56 157 | 158 | **Contributor:** Theo Gravity 159 | 160 | - Improve readme for Apollo GraphQL 161 | 162 | The instructions for Apollo GraphQL was not correct and has been updated with an internally tested example. 163 | 164 | ## 2.0.1 - Sun Mar 14 2021 05:42:56 165 | 166 | **Contributor:** Theo Gravity 167 | 168 | - New major version: v2 (#12) 169 | 170 | For most users, this new major version should not break your existing code. 171 | 172 | You may have to make adjustments if you happen to use generics in `ErrorRegistry`. 173 | 174 | - Potentially breaking: Refactor `ErrorRegistry` generics by removing unused generics and moving the definitions to an interface 175 | - Added the ability to define a `message` in a high level definition. This is used with `ErrorRegistry#newBareError` if no message is defined. 176 | - Made the `message` parameter of `ErrorRegistry#newBareError` optional. See readme for behavior when the parameter is omitted. 177 | 178 | ## 1.3.1 - Sun Mar 14 2021 03:09:54 179 | 180 | **Contributor:** Theo Gravity 181 | 182 | - Add the ability to convert an error to another type (#11) 183 | 184 | This is useful if you need to convert the errors created by this library into another type, such as a `GraphQLError` when going outbound to the client. 185 | 186 | See the README for more details. 187 | 188 | ## 1.2.10 - Fri Mar 12 2021 02:21:26 189 | 190 | **Contributor:** Theo Gravity 191 | 192 | - Fix typescript error when using utility helpers with registry and calling newError 193 | 194 | ## 1.2.9 - Tue Mar 09 2021 22:34:18 195 | 196 | **Contributor:** Theo Gravity 197 | 198 | - Add helper utilities for registry definitions 199 | 200 | Two utility methods have been defined: 201 | 202 | - `generateHighLevelErrors()` 203 | - `generateLowLevelErrors()` 204 | 205 | See readme for more details. 206 | 207 | ## 1.2.8 - Tue Mar 09 2021 03:52:59 208 | 209 | **Contributor:** Theo Gravity 210 | 211 | - Update readme 212 | 213 | ## 1.2.7 - Tue Mar 09 2021 01:55:52 214 | 215 | **Contributor:** Theo Gravity 216 | 217 | - Add `ErrorRegistry` config option `onCreateError` 218 | 219 | You can now globally modify new errors created from the error registry via the `onCreateError` handler. 220 | 221 | ## 1.2.6 - Tue Mar 09 2021 00:23:04 222 | 223 | **Contributor:** Theo Gravity 224 | 225 | - Clean up stack traces 226 | 227 | If you are calling `ErrorRegistry#newError` or related functions to create errors, the stack trace includes an `ErrorRegistry` entry. This change removes that entry for easier readability. 228 | 229 | ## 1.2.5 - Mon Mar 08 2021 22:54:45 230 | 231 | **Contributor:** Theo Gravity 232 | 233 | - Fix bug where specifying something other than an array for toJSON/toJSONSafe throws 234 | 235 | ## 1.2.4 - Mon Mar 08 2021 22:05:16 236 | 237 | **Contributor:** Theo Gravity 238 | 239 | - Update readme 240 | 241 | Fix handler example, add config usage 242 | 243 | ## 1.2.3 - Mon Mar 08 2021 21:59:38 244 | 245 | **Contributor:** Theo Gravity 246 | 247 | - Add toJSON / toJSONSafe post-processing handler options (#10) 248 | 249 | You can now perform post-processing on serialized data via `onPreToJSONData` / `onPreToJSONDataSafe` options. See readme for more details. 250 | 251 | ## 1.2.2 - Mon Mar 08 2021 21:06:07 252 | 253 | **Contributor:** Theo Gravity 254 | 255 | - Add option to not include empty metadata on serialization (#9) 256 | 257 | This adds `omitEmptyMetadata` to the `BaseError` configuration. 258 | 259 | ## 1.2.1 - Mon Mar 08 2021 20:33:41 260 | 261 | **Contributor:** Theo Gravity 262 | 263 | - Add configuration options for ErrorRegistry / BaseError (#8) 264 | 265 | New configuration options have been added to the ErrorRegistry and BaseError. See readme for more details. 266 | 267 | ## 1.1.2 - Mon Sep 21 2020 04:13:44 268 | 269 | **Contributor:** Theo Gravity 270 | 271 | - Fix README.md 272 | 273 | ## 1.1.1 - Mon Sep 21 2020 03:57:31 274 | 275 | **Contributor:** Theo Gravity 276 | 277 | - Add deserialization support (#7) 278 | - Include `logLevel` as part of `toJSON()` 279 | - Fix interface definitions and examples 280 | 281 | Please read the README section on the limitations and security issues relating to deserialization. 282 | 283 | ## 1.0.13 - Sat Jun 20 2020 03:22:14 284 | 285 | **Contributor:** Theo Gravity 286 | 287 | - Add support for defining log levels 288 | 289 | This adds the `logLevel` property to the error definitions along with 290 | corresponding `getLogLevel()` and `withLogLevel()` methods. 291 | 292 | There are cases where certain errors do not warrant being logged 293 | under an `error` log level when combined with a logging system. 294 | 295 | ## 1.0.12 - Wed Jun 03 2020 03:54:55 296 | 297 | **Contributor:** Theo Gravity 298 | 299 | - Update README.md 300 | 301 | Fix npm badge from http->https to fix render issues on the npm page 302 | 303 | ## 1.0.11 - Sun May 17 2020 22:42:30 304 | 305 | **Contributor:** Theo Gravity 306 | 307 | - Add `withErrorId()`, `getErrorId()` methods 308 | 309 | These methods were added to help cross-refeference an error between systems. The readme has been updated with usage and an example on a best-practice situation. 310 | 311 | ## 1.0.10 - Sun May 17 2020 21:02:32 312 | 313 | **Contributor:** Theo Gravity 314 | 315 | - Add new methods and documentation (#4) 316 | 317 | Added: 318 | 319 | - `BaseError#getErrorType()` 320 | - `BaseError#getErrorName()` 321 | 322 | ## 1.0.9 - Sat May 16 2020 00:39:12 323 | 324 | **Contributor:** Theo Gravity 325 | 326 | - Add missing methods to `IBaseError` (#3) 327 | 328 | ## 1.0.8 - Sat May 16 2020 00:20:35 329 | 330 | **Contributor:** Theo Gravity 331 | 332 | - Update readme with more examples 333 | 334 | ## 1.0.7 - Sat May 16 2020 00:05:10 335 | 336 | **Contributor:** Theo Gravity 337 | 338 | - Update readme with use-cases 339 | 340 | ## 1.0.6 - Fri May 15 2020 23:34:12 341 | 342 | **Contributor:** Theo Gravity 343 | 344 | - [minor] Concrete class-based support (#1) 345 | 346 | - Added examples on how to work with the library without the registry 347 | - Updated and exported some interfaces to assist with class-based creation 348 | 349 | ## 1.0.5 - Fri May 15 2020 20:57:38 350 | 351 | **Contributor:** Theo Gravity 352 | 353 | - Add getters / more examples / improved docs 354 | 355 | ## 1.0.4 - Fri May 15 2020 19:37:42 356 | 357 | **Contributor:** Theo Gravity 358 | 359 | - CI fixes. 360 | 361 | ## 1.0.3 - Fri May 15 2020 19:28:29 362 | 363 | **Contributor:** Theo Gravity 364 | 365 | - First version 366 | 367 | -------------------------------------------------------------------------------- /src/__tests__/ErrorRegistry.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { ErrorRegistry } from '../ErrorRegistry' 4 | import { BaseError, generateHighLevelErrors, generateLowLevelErrors } from '..' 5 | 6 | const errors = { 7 | INTERNAL_SERVER_ERROR: { 8 | className: 'InternalServerError', 9 | code: 'INT_ERR', 10 | message: 'Internal server error' 11 | }, 12 | AUTH_ERROR: { 13 | className: 'AuthError', 14 | code: 'AUTH_ERR' 15 | } 16 | } 17 | 18 | const errorCodes = { 19 | DATABASE_FAILURE: { 20 | statusCode: 500, 21 | subCode: 'DB_ERR', 22 | message: 'There is an issue with the database' 23 | } 24 | } 25 | 26 | describe('ErrorRegistry', () => { 27 | it('should create an instance', () => { 28 | const registry = new ErrorRegistry(errors, errorCodes) 29 | expect(registry).toBeDefined() 30 | }) 31 | 32 | it('should get a class definition of an error', () => { 33 | const registry = new ErrorRegistry(errors, errorCodes) 34 | const C = registry.getClass('INTERNAL_SERVER_ERROR') 35 | expect(C.name).toBe(errors.INTERNAL_SERVER_ERROR.className) 36 | }) 37 | 38 | it('should throw if a class definition does not exist', () => { 39 | const registry = new ErrorRegistry(errors, errorCodes) 40 | 41 | // @ts-ignore 42 | expect(() => registry.getClass('invalid')).toThrowError(/not defined/) 43 | }) 44 | 45 | it('should compare instances of an error', () => { 46 | const registry = new ErrorRegistry(errors, errorCodes) 47 | const err = registry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 48 | expect(registry.instanceOf(err, 'INTERNAL_SERVER_ERROR')).toBe(true) 49 | expect(registry.instanceOf(err, 'AUTH_ERROR')).toBe(false) 50 | }) 51 | 52 | describe('newBareError', () => { 53 | it('should create a bare error instance with a message', () => { 54 | const registry = new ErrorRegistry(errors, errorCodes) 55 | const err = registry.newBareError( 56 | 'INTERNAL_SERVER_ERROR', 57 | 'bare error msg' 58 | ) 59 | 60 | expect(registry.instanceOf(err, 'INTERNAL_SERVER_ERROR')) 61 | 62 | expect(err.toJSON()).toEqual( 63 | expect.objectContaining({ 64 | message: 'bare error msg' 65 | }) 66 | ) 67 | }) 68 | 69 | it('should create a bare error instance using the high level error', () => { 70 | const registry = new ErrorRegistry(errors, errorCodes) 71 | const err = registry.newBareError('INTERNAL_SERVER_ERROR') 72 | 73 | expect(registry.instanceOf(err, 'INTERNAL_SERVER_ERROR')) 74 | 75 | expect(err.toJSON()).toEqual( 76 | expect.objectContaining({ 77 | message: 'Internal server error' 78 | }) 79 | ) 80 | }) 81 | 82 | it('should create a bare error instance with the code as the message', () => { 83 | const registry = new ErrorRegistry(errors, errorCodes) 84 | const err = registry.newBareError('AUTH_ERROR') 85 | 86 | expect(registry.instanceOf(err, 'AUTH_ERROR')) 87 | 88 | expect(err.toJSON()).toEqual( 89 | expect.objectContaining({ 90 | message: errors.AUTH_ERROR.code 91 | }) 92 | ) 93 | }) 94 | 95 | it('should throw if a bare error class def does not exist', () => { 96 | const registry = new ErrorRegistry(errors, errorCodes) 97 | 98 | // @ts-ignore 99 | expect(() => registry.newBareError('invalid', 'msg')).toThrowError( 100 | /not defined/ 101 | ) 102 | }) 103 | }) 104 | 105 | it('should create an error instance', () => { 106 | const registry = new ErrorRegistry(errors, errorCodes) 107 | const err = registry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 108 | 109 | expect(registry.instanceOf(err, 'INTERNAL_SERVER_ERROR')) 110 | expect(err instanceof BaseError).toBe(true) 111 | expect(err.toJSON()).toEqual( 112 | expect.objectContaining({ 113 | ...errorCodes.DATABASE_FAILURE 114 | }) 115 | ) 116 | }) 117 | 118 | it('should create an error instance without the ErrorRegistry reference', () => { 119 | const registry = new ErrorRegistry(errors, errorCodes) 120 | const err = registry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 121 | 122 | expect(err.stack).not.toContain('at ErrorRegistry.newError') 123 | }) 124 | 125 | it('should call the onCreateError handler', () => { 126 | const registry = new ErrorRegistry(errors, errorCodes, { 127 | onCreateError: err => { 128 | err.withErrorId('test-id') 129 | } 130 | }) 131 | 132 | const err = registry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 133 | expect(err.getErrorId()).toBe('test-id') 134 | const err2 = registry.newBareError( 135 | 'INTERNAL_SERVER_ERROR', 136 | 'DATABASE_FAILURE' 137 | ) 138 | expect(err2.getErrorId()).toBe('test-id') 139 | }) 140 | 141 | it('should throw if a low error code does not exist', () => { 142 | const registry = new ErrorRegistry(errors, errorCodes) 143 | 144 | expect(() => 145 | // @ts-ignore 146 | registry.newError('INTERNAL_SERVER_ERROR', 'invalid') 147 | ).toThrowError(/Low level error/) 148 | }) 149 | 150 | it('should create an error instance with base error config', () => { 151 | const registry = new ErrorRegistry(errors, errorCodes, { 152 | baseErrorConfig: { 153 | toJSONFieldsToOmit: ['errId'], 154 | toJSONSafeFieldsToOmit: ['errId'] 155 | } 156 | }) 157 | 158 | const err = registry 159 | .newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 160 | .withErrorId('test-id') 161 | 162 | const json = err.toJSON() 163 | const jsonSafe = err.toJSONSafe() 164 | 165 | expect(json.errId).not.toBeDefined() 166 | expect(jsonSafe.errId).not.toBeDefined() 167 | 168 | const err2 = registry 169 | .newBareError('INTERNAL_SERVER_ERROR', 'test') 170 | .withErrorId('test-id') 171 | 172 | const json2 = err2.toJSON() 173 | const jsonSafe2 = err2.toJSONSafe() 174 | 175 | expect(json2.errId).not.toBeDefined() 176 | expect(jsonSafe2.errId).not.toBeDefined() 177 | }) 178 | 179 | it('should accept a definition from generateHighLevelErrors / generateLowLevelErrors', () => { 180 | const hl = generateHighLevelErrors({ 181 | HIGH_LV_ERR: {}, 182 | HIGHER_LV_ERR: { 183 | code: 'HIGHER' 184 | } 185 | }) 186 | 187 | const ll = generateLowLevelErrors({ 188 | LOW_LV_ERR: { 189 | message: 'test' 190 | }, 191 | LOWER_LV_ERR: { 192 | message: 'test2', 193 | subCode: 'LOWER_LV_ERR' 194 | } 195 | }) 196 | 197 | const registry = new ErrorRegistry(hl, ll) 198 | 199 | expect(registry.newError('HIGH_LV_ERR', 'LOW_LV_ERR')).toBeDefined() 200 | }) 201 | 202 | describe('withContext', () => { 203 | it('should create a new registry and error with context', () => { 204 | const registry = new ErrorRegistry(errors, errorCodes) 205 | const contextRegistry = registry.withContext({ 206 | metadata: { 207 | contextA: 'context-a' 208 | }, 209 | safeMetadata: { 210 | contextB: 'context-b' 211 | } 212 | }) 213 | 214 | Object.keys(registry).forEach(property => { 215 | // this will be different between the two 216 | if (property === '_newErrorContext') { 217 | return 218 | } 219 | 220 | expect(contextRegistry[property]).toEqual(registry[property]) 221 | }) 222 | 223 | const err = registry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 224 | const errContext = contextRegistry.newError( 225 | 'INTERNAL_SERVER_ERROR', 226 | 'DATABASE_FAILURE' 227 | ) 228 | 229 | expect(err.toJSON().meta).toEqual({}) 230 | 231 | expect(errContext.toJSON().meta).toEqual({ 232 | contextA: 'context-a', 233 | contextB: 'context-b' 234 | }) 235 | 236 | err.withMetadata({ 237 | test: 'test' 238 | }) 239 | 240 | expect(err.toJSON().meta).toEqual({ 241 | test: 'test' 242 | }) 243 | 244 | errContext.withMetadata({ 245 | test2: 'test2' 246 | }) 247 | 248 | expect(errContext.toJSON().meta).toEqual({ 249 | contextA: 'context-a', 250 | contextB: 'context-b', 251 | test2: 'test2' 252 | }) 253 | }) 254 | 255 | it('should not add any data if none is specified', () => { 256 | const registry = new ErrorRegistry(errors, errorCodes) 257 | const contextRegistry = registry.withContext({}) 258 | 259 | const errContext = contextRegistry.newError( 260 | 'INTERNAL_SERVER_ERROR', 261 | 'DATABASE_FAILURE' 262 | ) 263 | expect(errContext.toJSON().meta).toEqual({}) 264 | }) 265 | }) 266 | 267 | describe('deserialization', () => { 268 | it('should throw if data is not an object', () => { 269 | const registry = new ErrorRegistry(errors, errorCodes) 270 | 271 | // @ts-ignore 272 | expect(() => registry.fromJSON('')).toThrowError() 273 | }) 274 | 275 | it('should deserialize into a custom error', () => { 276 | const registry = new ErrorRegistry(errors, errorCodes) 277 | 278 | const data = { 279 | errId: 'err-123', 280 | code: 'ERR_INT_500', 281 | subCode: 'DB_0001', 282 | message: 'test message', 283 | meta: { safe: 'test454', test: 'test123' }, 284 | name: 'InternalServerError', 285 | statusCode: 500, 286 | causedBy: 'test', 287 | stack: 'abcd' 288 | } 289 | 290 | const err = registry.fromJSON(data) 291 | 292 | expect(registry.instanceOf(err, 'INTERNAL_SERVER_ERROR')).toBe(true) 293 | }) 294 | 295 | it('should deserialize into a base error', () => { 296 | const registry = new ErrorRegistry(errors, errorCodes) 297 | 298 | const data = { 299 | errId: 'err-123', 300 | code: 'ERR_INT_500', 301 | subCode: 'DB_0001', 302 | message: 'test message', 303 | meta: { safe: 'test454', test: 'test123' }, 304 | name: 'invalid', 305 | statusCode: 500, 306 | causedBy: 'test', 307 | stack: 'abcd' 308 | } 309 | 310 | const err = registry.fromJSON(data) 311 | 312 | expect(registry.instanceOf(err, 'INTERNAL_SERVER_ERROR')).toBe(false) 313 | }) 314 | }) 315 | 316 | describe('conversion handler', () => { 317 | const err = new Error('err') 318 | const PERM_ERR_STR = 'permission error' 319 | 320 | const errors = { 321 | PERMISSION_REQUIRED: { 322 | className: 'PermissionRequiredError', 323 | code: 'PERMISSION_REQUIRED', 324 | onConvert: () => { 325 | return PERM_ERR_STR 326 | } 327 | }, 328 | AUTH_REQUIRED: { 329 | className: 'AuthRequiredError', 330 | code: 'AUTH_REQUIRED' 331 | } 332 | } 333 | 334 | const errorCodes = { 335 | ADMIN_PANEL_RESTRICTED: { 336 | message: 'Access scope required: admin', 337 | onConvert: () => { 338 | return err 339 | } 340 | }, 341 | EDITOR_SECTION_RESTRICTED: { 342 | message: 'Access scope required: editor' 343 | } 344 | } 345 | 346 | const errRegistry = new ErrorRegistry(errors, errorCodes) 347 | 348 | it('should call onConvert for a subcategory', () => { 349 | expect( 350 | errRegistry 351 | .newError('PERMISSION_REQUIRED', 'ADMIN_PANEL_RESTRICTED') 352 | .convert() 353 | ).toEqual(err) 354 | 355 | expect( 356 | errRegistry 357 | .newError('AUTH_REQUIRED', 'ADMIN_PANEL_RESTRICTED') 358 | .convert() 359 | ).toEqual(err) 360 | }) 361 | 362 | it('should call onConvert for a category', () => { 363 | expect( 364 | errRegistry 365 | .newError('PERMISSION_REQUIRED', 'EDITOR_SECTION_RESTRICTED') 366 | .convert() 367 | ).toEqual(PERM_ERR_STR) 368 | 369 | expect( 370 | errRegistry.newBareError('PERMISSION_REQUIRED', 'test').convert() 371 | ).toEqual(PERM_ERR_STR) 372 | }) 373 | 374 | it('should return the error when onConvert is not defined', () => { 375 | let baseErr = errRegistry.newError( 376 | 'AUTH_REQUIRED', 377 | 'EDITOR_SECTION_RESTRICTED' 378 | ) 379 | 380 | expect(baseErr.convert()).toEqual(baseErr) 381 | 382 | baseErr = errRegistry.newBareError('AUTH_REQUIRED', 'test') 383 | 384 | expect(baseErr.convert()).toEqual(baseErr) 385 | }) 386 | }) 387 | }) 388 | -------------------------------------------------------------------------------- /src/error-types/BaseError.ts: -------------------------------------------------------------------------------- 1 | import { vsprintf } from 'sprintf-js' 2 | import ExtendableError from 'es6-error' 3 | import { 4 | ConvertedType, 5 | ConvertFn, 6 | DeserializeOpts, 7 | IBaseError, 8 | IBaseErrorConfig, 9 | SerializedError, 10 | SerializedErrorSafe 11 | } from '../interfaces' 12 | 13 | /** 14 | * Improved error class. 15 | */ 16 | export class BaseError extends ExtendableError implements IBaseError { 17 | protected _errId: string 18 | protected _reqId: string 19 | protected _type: string 20 | protected _code: string | number 21 | protected _subCode: string | number 22 | protected _statusCode: any 23 | protected _causedBy: any 24 | protected _safeMetadata: Record 25 | protected _metadata: Record 26 | protected _logLevel: string | number 27 | protected _config: IBaseErrorConfig 28 | protected _hasMetadata: boolean 29 | protected _hasSafeMetadata: boolean 30 | protected _onConvert: ConvertFn | null 31 | protected _appendedWithErrorMsg: boolean 32 | 33 | constructor (message: string, config: IBaseErrorConfig = {}) { 34 | super(message) 35 | 36 | this._safeMetadata = {} 37 | this._metadata = {} 38 | this._hasMetadata = false 39 | this._hasSafeMetadata = false 40 | this._config = config || {} 41 | this._onConvert = this._config.onConvert || null 42 | this._appendedWithErrorMsg = false 43 | } 44 | 45 | /** 46 | * Assign a log level to the error. Useful if you want to 47 | * determine which log level to use when logging the error. 48 | * @param {string|number} logLevel 49 | */ 50 | withLogLevel (logLevel: string | number) { 51 | this._logLevel = logLevel 52 | return this 53 | } 54 | 55 | /** 56 | * Set an error id used to link back to the specific error 57 | * @param errId 58 | */ 59 | withErrorId (errId: string) { 60 | this._errId = errId 61 | return this 62 | } 63 | 64 | /** 65 | * Set a request id used to link back to the specific error 66 | * @param requestId 67 | */ 68 | withRequestId (requestId: string) { 69 | this._reqId = requestId 70 | return this 71 | } 72 | 73 | /** 74 | * Set the error type 75 | * @param type 76 | */ 77 | withErrorType (type: string) { 78 | this._type = type 79 | return this 80 | } 81 | 82 | /** 83 | * Set high level error code 84 | * @param code 85 | */ 86 | withErrorCode (code: string | number) { 87 | this._code = code 88 | return this 89 | } 90 | 91 | /** 92 | * Set low level error code 93 | * @param subCode 94 | */ 95 | withErrorSubCode (subCode: string | number) { 96 | this._subCode = subCode 97 | return this 98 | } 99 | 100 | /** 101 | * Gets the log level assigned to the error 102 | */ 103 | getLogLevel () { 104 | return this._logLevel 105 | } 106 | 107 | /** 108 | * Get the instance-specific error id 109 | */ 110 | getErrorId () { 111 | return this._errId 112 | } 113 | 114 | /** 115 | * Get the request id associated with the error 116 | */ 117 | getRequestId () { 118 | return this._reqId 119 | } 120 | 121 | /** 122 | * Get the class name of the error 123 | */ 124 | getErrorName () { 125 | return this.name 126 | } 127 | 128 | /** 129 | * Get the low level error type 130 | */ 131 | getErrorType () { 132 | return this._type 133 | } 134 | 135 | /** 136 | * Returns the status code. 137 | */ 138 | getStatusCode () { 139 | return this._statusCode 140 | } 141 | 142 | /** 143 | * Returns the high level error code 144 | */ 145 | getCode () { 146 | return this._code 147 | } 148 | 149 | /** 150 | * Returns the low level error code 151 | */ 152 | getSubCode () { 153 | return this._subCode 154 | } 155 | 156 | /** 157 | * Returns the attached error 158 | */ 159 | getCausedBy (): any { 160 | return this._causedBy 161 | } 162 | 163 | /** 164 | * Returns metadata set by withMetadata() 165 | */ 166 | getMetadata (): Record { 167 | return this._metadata 168 | } 169 | 170 | /** 171 | * Returns metadata set by withSafeMetadata() 172 | */ 173 | getSafeMetadata (): Record { 174 | return this._safeMetadata 175 | } 176 | 177 | /** 178 | * Gets the error config 179 | */ 180 | getConfig (): IBaseErrorConfig { 181 | return this._config 182 | } 183 | 184 | /** 185 | * Sets the error config 186 | */ 187 | setConfig (config: IBaseErrorConfig) { 188 | this._config = config 189 | return this 190 | } 191 | 192 | /** 193 | * Replaces sprintf() flags in an error message, if present. 194 | * @see https://www.npmjs.com/package/sprintf-js 195 | * @param args 196 | */ 197 | formatMessage (...args) { 198 | this.message = vsprintf(this.message, args) 199 | this.appendCausedByMessage() 200 | return this 201 | } 202 | 203 | /** 204 | * Attach the original error that was thrown, if available 205 | * @param {Error} error 206 | */ 207 | causedBy (error: any) { 208 | this._causedBy = error 209 | this.appendCausedByMessage() 210 | return this 211 | } 212 | 213 | /** 214 | * Appends the caused by message to the main error message if 215 | * the appendWithErrorMessageFormat config item is defined. 216 | */ 217 | protected appendCausedByMessage () { 218 | if ( 219 | !this._appendedWithErrorMsg && 220 | this._causedBy?.message && 221 | this._config.appendWithErrorMessageFormat 222 | ) { 223 | this.message = 224 | this.message + 225 | vsprintf( 226 | this._config.appendWithErrorMessageFormat, 227 | this._causedBy.message 228 | ) 229 | this._appendedWithErrorMsg = true 230 | } 231 | } 232 | 233 | /** 234 | * Adds metadata that will be included with toJSON() serialization. 235 | * Multiple calls will append and not replace. 236 | * @param {Object} metadata 237 | */ 238 | withMetadata (metadata: Record) { 239 | this._hasMetadata = true 240 | this._metadata = { 241 | ...this._metadata, 242 | ...metadata 243 | } 244 | return this 245 | } 246 | 247 | /** 248 | * Set a protocol-specific status code 249 | * @param statusCode 250 | */ 251 | withStatusCode (statusCode: any) { 252 | this._statusCode = statusCode 253 | return this 254 | } 255 | 256 | /** 257 | * Adds metadata that will be included with toJSON() / toJsonSafe() 258 | * serialization. Multiple calls will append and not replace. 259 | * @param {Object} metadata 260 | */ 261 | withSafeMetadata (metadata: Record) { 262 | this._hasMetadata = true 263 | this._hasSafeMetadata = true 264 | this._safeMetadata = { 265 | ...this._safeMetadata, 266 | ...metadata 267 | } 268 | return this 269 | } 270 | 271 | /** 272 | * Returns a json representation of the error. Assume the data 273 | * contained is for internal purposes only as it contains the stack trace. 274 | * Use / implement toJsonSafe() to return data that is safe for client 275 | * consumption. 276 | * @param {string[]} [fieldsToOmit] An array of root properties to omit from the output 277 | */ 278 | toJSON (fieldsToOmit: string[] = []): Partial { 279 | let data: Partial = { 280 | errId: this._errId, 281 | reqId: this._reqId, 282 | name: this.name, 283 | code: this._code, 284 | message: this.message, 285 | type: this._type, 286 | subCode: this._subCode, 287 | statusCode: this._statusCode, 288 | logLevel: this._logLevel, 289 | meta: { 290 | ...this._metadata, 291 | ...this._safeMetadata 292 | }, 293 | causedBy: this._causedBy, 294 | stack: this.stack 295 | } 296 | 297 | if ( 298 | !this._hasSafeMetadata && 299 | !this._hasMetadata && 300 | this._config.omitEmptyMetadata 301 | ) { 302 | delete data.meta 303 | } 304 | 305 | if (typeof this._config.onPreToJSONData === 'function') { 306 | data = this._config.onPreToJSONData(data) 307 | } 308 | 309 | Object.keys(data).forEach(item => { 310 | // remove undefined items 311 | if (data[item] === undefined) { 312 | delete data[item] 313 | } 314 | }) 315 | 316 | if (Array.isArray(fieldsToOmit)) { 317 | fieldsToOmit.forEach(item => { 318 | delete data[item] 319 | }) 320 | } 321 | 322 | if (Array.isArray(this._config.toJSONFieldsToOmit)) { 323 | this._config.toJSONFieldsToOmit.forEach(item => { 324 | delete data[item] 325 | }) 326 | } 327 | 328 | return data 329 | } 330 | 331 | /** 332 | * Returns a safe json representation of the error (error stack / causedBy is removed). 333 | * This should be used for display to a user / pass to a client. 334 | * @param {string[]} [fieldsToOmit] An array of root properties to omit from the output 335 | */ 336 | toJSONSafe (fieldsToOmit: string[] = []): Partial { 337 | let data: Partial = { 338 | errId: this._errId, 339 | reqId: this._reqId, 340 | code: this._code, 341 | subCode: this._subCode, 342 | statusCode: this._statusCode, 343 | meta: { 344 | ...this._safeMetadata 345 | } 346 | } 347 | 348 | if (!this._hasSafeMetadata && this._config.omitEmptyMetadata) { 349 | delete data.meta 350 | } 351 | 352 | Object.keys(data).forEach(item => { 353 | // remove undefined items 354 | if (data[item] === undefined) { 355 | delete data[item] 356 | } 357 | }) 358 | 359 | if (typeof this._config.onPreToJSONSafeData === 'function') { 360 | data = this._config.onPreToJSONSafeData(data) 361 | } 362 | 363 | if (Array.isArray(fieldsToOmit)) { 364 | fieldsToOmit.forEach(item => { 365 | delete data[item] 366 | }) 367 | } 368 | 369 | if (Array.isArray(this._config.toJSONSafeFieldsToOmit)) { 370 | this._config.toJSONSafeFieldsToOmit.forEach(item => { 371 | delete data[item] 372 | }) 373 | } 374 | 375 | return data 376 | } 377 | 378 | /** 379 | * Calls the user-defined `onConvert` function to convert the error into another type. 380 | * If `onConvert` is not defined, then returns the error itself. 381 | */ 382 | convert (): T { 383 | if (this.hasOnConvertDefined()) { 384 | return this._onConvert.bind(this)(this) as T 385 | } 386 | 387 | return this as any 388 | } 389 | 390 | /** 391 | * Returns true if the onConvert handler is defined 392 | */ 393 | hasOnConvertDefined (): boolean { 394 | return typeof this._onConvert === 'function' 395 | } 396 | 397 | /** 398 | * Set the onConvert handler for convert() calls. 399 | * This can also be defined via the onConvert config property in the constructor. 400 | */ 401 | setOnConvert (convertFn: ConvertFn) { 402 | this._onConvert = convertFn 403 | } 404 | 405 | /** 406 | * Helper method for use with fromJson() 407 | * @param errInstance An error instance that extends BaseError 408 | * @param {string} data JSON.parse()'d error object from 409 | * BaseError#toJSON() or BaseError#toJSONSafe() 410 | * @param {DeserializeOpts} [opts] Deserialization options 411 | */ 412 | static copyDeserializationData< 413 | T extends IBaseError = IBaseError, 414 | U extends DeserializeOpts = DeserializeOpts 415 | > (errInstance: T, data: Partial, opts: U) { 416 | if (data.code) { 417 | errInstance.withErrorCode(data.code) 418 | } 419 | 420 | if (data.subCode) { 421 | errInstance.withErrorSubCode(data.subCode) 422 | } 423 | 424 | if (data.errId) { 425 | errInstance.withErrorId(data.errId) 426 | } 427 | 428 | if (data.reqId) { 429 | errInstance.withRequestId(data.reqId) 430 | } 431 | 432 | if (data.statusCode) { 433 | errInstance.withStatusCode(data.statusCode) 434 | } 435 | 436 | if (data.stack) { 437 | errInstance.stack = data.stack 438 | } 439 | 440 | if (data.logLevel) { 441 | errInstance.withLogLevel(data.logLevel) 442 | } 443 | 444 | // not possible to know what the underlying causedBy type is 445 | // so we can't deserialize to its original representation 446 | if (data.causedBy) { 447 | errInstance.causedBy(data.causedBy) 448 | } 449 | 450 | // if defined, pluck the metadata fields to their respective safe and unsafe counterparts 451 | if (data.meta && opts && opts.safeMetadataFields) { 452 | Object.keys(data.meta).forEach(key => { 453 | if (opts.safeMetadataFields[key]) { 454 | errInstance.withSafeMetadata({ 455 | [key]: data.meta[key] 456 | }) 457 | } else { 458 | errInstance.withMetadata({ 459 | [key]: data.meta[key] 460 | }) 461 | } 462 | }) 463 | } else { 464 | errInstance.withMetadata(data.meta || {}) 465 | } 466 | } 467 | 468 | /** 469 | * Deserializes an error into an instance 470 | * @param {string} data JSON.parse()'d error object from 471 | * BaseError#toJSON() or BaseError#toJSONSafe() 472 | * @param {DeserializeOpts} [opts] Deserialization options 473 | */ 474 | static fromJSON ( 475 | data: Partial, 476 | opts?: T 477 | ): IBaseError { 478 | if (!opts) { 479 | opts = {} as T 480 | } 481 | 482 | if (typeof data !== 'object') { 483 | throw new Error('fromJSON(): Data is not an object.') 484 | } 485 | 486 | const err = new this(data.message) 487 | 488 | this.copyDeserializationData(err, data, opts) 489 | 490 | return err 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A High Level Error definition defined by the user 3 | */ 4 | import { BaseRegistryError } from './error-types/BaseRegistryError' 5 | import { BaseError } from './error-types/BaseError' 6 | 7 | export interface HighLevelError { 8 | /** 9 | * A user-friendly code to show to a client that represents the high 10 | * level error. 11 | */ 12 | code: string | number 13 | 14 | /** 15 | * Protocol-specific status code, such as an HTTP status code. Used as the 16 | * default if a Low Level Error status code is not specified or defined. 17 | */ 18 | statusCode?: string | number 19 | 20 | /** 21 | * A log level to associate with this error type since not all errors 22 | * may be considered an 'error' log level type when combined with 23 | * a logger. Used as the default if a Low Level Error log level 24 | * is not defined. 25 | */ 26 | logLevel?: string | number 27 | 28 | /** 29 | * Callback function to call when calling BaseError#convert(). 30 | * 31 | * (baseError) => any type 32 | * 33 | * - If not defined, will return itself when convert() is called 34 | * - If defined in HighLevelError, the HighLevelError definition takes priority 35 | */ 36 | onConvert?: ConvertFn 37 | 38 | /** 39 | * Full description of the error. Used only when BaseError#newBareError() is called without 40 | * the message parameter. 41 | * 42 | * sprintf() flags can be applied to customize it. 43 | * @see https://www.npmjs.com/package/sprintf-js 44 | */ 45 | message?: string 46 | } 47 | 48 | /** 49 | * A High Level Error definition defined by the user for a registry 50 | */ 51 | export interface HighLevelErrorInternal extends HighLevelError { 52 | /** 53 | * The class name of the generated error 54 | */ 55 | className: string 56 | } 57 | 58 | /** 59 | * A Low Level Error definition defined by the user 60 | */ 61 | export interface LowLevelErrorDef { 62 | /** 63 | * Full description of the error. sprintf() flags can be applied 64 | * to customize it. 65 | * @see https://www.npmjs.com/package/sprintf-js 66 | */ 67 | message: string 68 | 69 | /** 70 | * A user-friendly code to show to a client that represents the low 71 | * level error. 72 | */ 73 | subCode?: string | number 74 | 75 | /** 76 | * Protocol-specific status code, such as an HTTP status code. 77 | */ 78 | statusCode?: string | number 79 | 80 | /** 81 | * A log level to associate with this error type since not all errors 82 | * may be considered an 'error' log level type when combined with 83 | * a logger. 84 | */ 85 | logLevel?: string | number 86 | 87 | /** 88 | * Callback function to call when calling BaseError#convert(). 89 | * 90 | * (baseError) => any type 91 | * 92 | * - If not defined, will return itself when convert() is called 93 | * - This definition takes priority if HighLevelError#onConvert is defined 94 | */ 95 | onConvert?: ConvertFn 96 | } 97 | 98 | /** 99 | * Low Level Error stored in the ErrorRegistry 100 | */ 101 | export interface LowLevelErrorInternal extends LowLevelErrorDef { 102 | /** 103 | * Name of the Low Level Error 104 | */ 105 | type?: string 106 | } 107 | 108 | /** 109 | * A Low Level Error definition defined by the user when a registry is not involved 110 | */ 111 | export interface LowLevelError extends LowLevelErrorDef { 112 | /** 113 | * Name of the Low Level Error 114 | */ 115 | type: string 116 | } 117 | 118 | export interface IBaseError { 119 | /** 120 | * Get the instance-specific error id 121 | */ 122 | getErrorId(): string 123 | 124 | /** 125 | * Get the class name of the error 126 | */ 127 | getErrorName(): string 128 | 129 | /** 130 | * Gets the log level assigned to the error. If a low level code 131 | * has a log level defined, it will be used over the high level one. 132 | */ 133 | getLogLevel(): string | number 134 | 135 | /** 136 | * Returns the high level error code 137 | */ 138 | getCode(): string | number 139 | 140 | /** 141 | * Returns the low level error code 142 | */ 143 | getSubCode(): string | number 144 | 145 | /** 146 | * Returns the status code. 147 | */ 148 | getStatusCode(): string | number 149 | 150 | /** 151 | * Get the low level error type 152 | */ 153 | getErrorType(): string 154 | 155 | /** 156 | * Returns the attached error 157 | */ 158 | getCausedBy(): any 159 | 160 | /** 161 | * Returns metadata set by withMetadata() 162 | */ 163 | getMetadata(): Record 164 | 165 | /** 166 | * Returns metadata set by withSafeMetadata() 167 | */ 168 | getSafeMetadata(): Record 169 | 170 | /** 171 | * Gets the configuration options 172 | */ 173 | getConfig(): IBaseErrorConfig 174 | 175 | /** 176 | * Sets configuration options 177 | */ 178 | setConfig(config: IBaseErrorConfig): void 179 | 180 | /** 181 | * Set the onConvert handler for convert() calls. 182 | * This can also be defined via the onConvert config property in the constructor. 183 | */ 184 | setOnConvert(convertFn: ConvertFn): void 185 | 186 | /** 187 | * Attach the original error that was thrown, if available 188 | * @param {Error} error 189 | */ 190 | causedBy(error: any): this 191 | 192 | /** 193 | * Set an error id used to link back to the specific error 194 | * @param {string} errId 195 | */ 196 | withErrorId(errId: string): this 197 | 198 | /** 199 | * Set an request id used to link back to the specific error 200 | * @param {string} reqId 201 | */ 202 | withRequestId(reqId: string): this 203 | 204 | /** 205 | * Set the error type 206 | * @param type 207 | */ 208 | withErrorType(type: string): this 209 | 210 | /** 211 | * Set high level error code 212 | * @param code 213 | */ 214 | withErrorCode(code: string | number): this 215 | 216 | /** 217 | * Set low level error code 218 | * @param subCode 219 | */ 220 | withErrorSubCode(subCode: string | number): this 221 | 222 | /** 223 | * Adds metadata that will be included with toJSON() serialization. 224 | * Multiple calls will append and not replace. 225 | * @param {Object} metadata 226 | */ 227 | withMetadata(metadata: Record): this 228 | 229 | /** 230 | * Adds metadata that will be included with toJSON() / toJsonSafe() 231 | * serialization. Multiple calls will append and not replace. 232 | * @param {Object} safeMetadata 233 | */ 234 | withSafeMetadata(safeMetadata: Record): this 235 | 236 | /** 237 | * Set a protocol-specific status code 238 | * @param statusCode 239 | */ 240 | withStatusCode(statusCode: any): this 241 | 242 | /** 243 | * Replaces printf flags in an error message, if present. 244 | * @see https://www.npmjs.com/package/sprintf-js 245 | * @param args 246 | */ 247 | formatMessage(...args): this 248 | 249 | /** 250 | * Assign a log level to the error. Useful if you want to 251 | * determine which log level to use when logging the error. 252 | * @param {string|number} logLevel 253 | */ 254 | withLogLevel(logLevel: string | number): this 255 | 256 | /** 257 | * Returns a json representation of the error. Assume the data 258 | * contained is for internal purposes only as it contains the stack trace. 259 | * Use / implement toJsonSafe() to return data that is safe for client 260 | * consumption. 261 | * @param {string[]} [fieldsToOmit] An array of root properties to omit from the output 262 | */ 263 | toJSON(fieldsToOmit?: string[]): Partial 264 | /** 265 | * Returns a safe json representation of the error (error stack / causedBy is removed). 266 | * This should be used for display to a user / pass to a client. 267 | * @param {string[]} [fieldsToOmit] An array of root properties to omit from the output 268 | */ 269 | toJSONSafe(fieldsToOmit?: string[]): Partial 270 | 271 | /** 272 | * Calls the user-defined `onConvert` function to convert the error into another type. 273 | * If `onConvert` is not defined, then returns the error itself. 274 | */ 275 | convert(): T 276 | 277 | /** 278 | * Returns true if the onConvert handler is defined 279 | */ 280 | hasOnConvertDefined(): boolean 281 | 282 | /** 283 | * Stack trace 284 | */ 285 | stack?: any 286 | } 287 | 288 | /** 289 | * Configuration options for the BaseError class 290 | */ 291 | export interface IBaseErrorConfig { 292 | /** 293 | * A list of fields to always omit when calling toJSON 294 | */ 295 | toJSONFieldsToOmit?: string[] 296 | /** 297 | * A list of fields to always omit when calling toJSONSafe 298 | */ 299 | toJSONSafeFieldsToOmit?: string[] 300 | /** 301 | * If the metadata has no data defined, remove the `meta` property on `toJSON` / `toJSONSafe` 302 | */ 303 | omitEmptyMetadata?: boolean 304 | /** 305 | * A function to run against the computed data when calling `toJSON`. This is called prior 306 | * to field omission. If defined, must return the data back. 307 | */ 308 | onPreToJSONData?: (data: Partial) => Partial 309 | /** 310 | * A function to run against the computed safe data when calling `toJSONSafe`. This is called 311 | * prior to field omission. If defined, must return the data back. 312 | */ 313 | onPreToJSONSafeData?: ( 314 | data: Partial 315 | ) => Partial 316 | 317 | /** 318 | * A callback function to call when calling BaseError#convert(). This allows for user-defined conversion 319 | * of the BaseError into some other type (such as an Apollo GraphQL error type). 320 | * 321 | * (baseError) => any type 322 | */ 323 | onConvert?: ConvertFn 324 | /** 325 | * If defined, will append the `.message` value when calling causedBy() after the main error message. 326 | * Useful for test frameworks like Jest where it will not print the caused by message. 327 | * To define the format of the appended message, use '%s' for the message value. 328 | * 329 | * See readme for examples. 330 | */ 331 | appendWithErrorMessageFormat?: string 332 | } 333 | 334 | /** 335 | * Configuration options for the ErrorRegistry class 336 | */ 337 | export interface IErrorRegistryConfig { 338 | /** 339 | * Options when creating a new BaseError 340 | */ 341 | baseErrorConfig?: IBaseErrorConfig 342 | /** 343 | * Handler to modify the created error when newError / newBareError is called 344 | */ 345 | onCreateError?: (err: BaseRegistryError) => void 346 | } 347 | 348 | export interface IErrorRegistryContextConfig { 349 | /** 350 | * Metadata to include for each new error created by the registry 351 | */ 352 | metadata?: Record 353 | /** 354 | * Safe metadata to include for each new error created by the registry 355 | */ 356 | safeMetadata?: Record 357 | } 358 | 359 | /** 360 | * Safe-version of a serialized error object that can be shown to a client / 361 | * end-user. 362 | */ 363 | export interface SerializedErrorSafe { 364 | /** 365 | * The error id 366 | */ 367 | errId?: string 368 | /** 369 | * The request id 370 | */ 371 | reqId?: string 372 | /** 373 | * The high level code to show to a client. 374 | */ 375 | code: any 376 | /** 377 | * The low level code to show to a client. 378 | */ 379 | subCode?: string | number 380 | /** 381 | * Protocol-specific status code, such as an HTTP status code. 382 | */ 383 | statusCode?: string | number 384 | 385 | /** 386 | * Assigned log level 387 | */ 388 | logLevel?: string | number 389 | 390 | /** 391 | * User-defined metadata. May not be present if there is no data and omitEmptyMetadata is enabled 392 | */ 393 | meta?: Record 394 | 395 | /** 396 | * Other optional values 397 | */ 398 | [key: string]: any 399 | } 400 | 401 | /** 402 | * Serialized error object 403 | */ 404 | export interface SerializedError extends SerializedErrorSafe { 405 | /** 406 | * Name of the High Level Error 407 | */ 408 | name: string 409 | /** 410 | * Name of the Low Level Error 411 | */ 412 | type: string 413 | /** 414 | * Message as defined in the Low Level Error 415 | */ 416 | message: string 417 | /** 418 | * Stack trace 419 | */ 420 | stack: string 421 | /** 422 | * If applicable, the original error that was thrown 423 | */ 424 | causedBy?: any 425 | } 426 | 427 | export interface DeserializeOpts { 428 | /** 429 | * Fields from meta to pluck as a safe metadata field 430 | */ 431 | safeMetadataFields?: Record 432 | } 433 | 434 | export interface GenerateHighLevelErrorOpts { 435 | /** 436 | * Disable to not generate the class name based on the property name if the className is not defined. 437 | */ 438 | disableGenerateClassName?: boolean 439 | /** 440 | * Disable to not generate the error code based on the property name if the code is not defined. 441 | */ 442 | disableGenerateCode?: boolean 443 | } 444 | 445 | export interface GenerateLowLevelErrorOpts { 446 | /** 447 | * Disable to not generate the error code based on the property name if the subCode is not defined. 448 | */ 449 | disableGenerateSubCode?: boolean 450 | } 451 | 452 | export type ConvertedType = any 453 | 454 | /** 455 | * onConvert function handler definition 456 | */ 457 | export type ConvertFn = ( 458 | err: E 459 | ) => ConvertedType 460 | 461 | /** 462 | * Alias for keyof w/ string only 463 | */ 464 | export type KeyOfStr = Extract 465 | 466 | /** 467 | * A collection of high level error definitions 468 | */ 469 | export type HLDefs< 470 | T extends string, 471 | HLDef extends HighLevelErrorInternal = HighLevelErrorInternal 472 | > = Record 473 | 474 | /** 475 | * A collection of low level error definitions 476 | */ 477 | export type LLDefs< 478 | T extends string, 479 | LLDef extends LowLevelErrorInternal = LowLevelErrorInternal 480 | > = Record 481 | -------------------------------------------------------------------------------- /src/error-types/__tests__/BaseError.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { BaseError } from '../BaseError' 4 | import { DeserializeOpts, SerializedError } from '../../interfaces' 5 | 6 | describe('BaseError', () => { 7 | it('should create an instance', () => { 8 | const err = new BaseError('test message') 9 | expect(err).toBeDefined() 10 | }) 11 | 12 | it('should format a message', () => { 13 | const err = new BaseError('test %s') 14 | err.formatMessage('blah') 15 | expect(err.message).toBe('test blah') 16 | }) 17 | 18 | it('should set the causedBy', () => { 19 | const cause = new Error('test caused by') 20 | 21 | const err = new BaseError('test %s') 22 | err.causedBy(cause) 23 | 24 | expect(err.toJSON()).toEqual( 25 | expect.objectContaining({ 26 | causedBy: cause 27 | }) 28 | ) 29 | }) 30 | 31 | it('should set metadata', () => { 32 | const err = new BaseError('test message') 33 | err 34 | .withMetadata({ 35 | test: 'value' 36 | }) 37 | .withMetadata({ 38 | test2: 'value2' 39 | }) 40 | 41 | expect(err.getMetadata()).toEqual({ 42 | test: 'value', 43 | test2: 'value2' 44 | }) 45 | 46 | expect(err.toJSON()).toEqual( 47 | expect.objectContaining({ 48 | meta: { 49 | test: 'value', 50 | test2: 'value2' 51 | } 52 | }) 53 | ) 54 | 55 | expect(err.toJSONSafe()).toEqual( 56 | expect.objectContaining({ 57 | meta: {} 58 | }) 59 | ) 60 | }) 61 | 62 | it('should set safe metadata', () => { 63 | const err = new BaseError('test message') 64 | err 65 | .withSafeMetadata({ 66 | test2: 'value' 67 | }) 68 | .withSafeMetadata({ 69 | test3: 123 70 | }) 71 | 72 | expect(err.getSafeMetadata()).toEqual({ 73 | test2: 'value', 74 | test3: 123 75 | }) 76 | 77 | expect(err.toJSON()).toEqual( 78 | expect.objectContaining({ 79 | meta: { 80 | test2: 'value', 81 | test3: 123 82 | } 83 | }) 84 | ) 85 | 86 | expect(err.toJSONSafe()).toEqual( 87 | expect.objectContaining({ 88 | meta: { 89 | test2: 'value', 90 | test3: 123 91 | } 92 | }) 93 | ) 94 | }) 95 | 96 | it('should allow a mix of safe and unsafe metadata', () => { 97 | const err = new BaseError('test message') 98 | err 99 | .withMetadata({ 100 | unsafe: 'value' 101 | }) 102 | .withSafeMetadata({ 103 | safe: 123 104 | }) 105 | 106 | expect(err.toJSON()).toEqual( 107 | expect.objectContaining({ 108 | meta: { 109 | unsafe: 'value', 110 | safe: 123 111 | } 112 | }) 113 | ) 114 | 115 | expect(err.toJSONSafe()).toEqual( 116 | expect.objectContaining({ 117 | meta: { 118 | unsafe: undefined, 119 | safe: 123 120 | } 121 | }) 122 | ) 123 | }) 124 | 125 | it('should show stack trace and causedBy when using toJSON()', () => { 126 | const cause = new Error('test caused by') 127 | const err = new BaseError('test message') 128 | err.causedBy(cause) 129 | 130 | const obj = err.toJSON() 131 | 132 | expect(obj).toEqual( 133 | expect.objectContaining({ 134 | message: 'test message', 135 | meta: {}, 136 | name: 'BaseError', 137 | causedBy: cause 138 | }) 139 | ) 140 | 141 | expect(err.getCausedBy()).toEqual(cause) 142 | 143 | expect(obj.stack).toBeDefined() 144 | }) 145 | 146 | it('should omit fields in toJSON()', () => { 147 | const cause = new Error('test caused by') 148 | const err = new BaseError('test message') 149 | err.causedBy(cause) 150 | 151 | const obj = err.toJSON(['causedBy']) 152 | 153 | expect(obj.causedBy).not.toBeDefined() 154 | }) 155 | 156 | it('should omit fields in toJSON() via config', () => { 157 | const cause = new Error('test caused by') 158 | const err = new BaseError('test message', { 159 | toJSONFieldsToOmit: ['causedBy'], 160 | omitEmptyMetadata: true 161 | }) 162 | err.causedBy(cause) 163 | 164 | const obj = err.toJSON() 165 | 166 | expect(obj.causedBy).not.toBeDefined() 167 | expect(obj.meta).not.toBeDefined() 168 | }) 169 | 170 | it('should not show stack trace or causedBy when using toJSONSafe()', () => { 171 | const cause = new Error('test caused by') 172 | const err = new BaseError('test message') 173 | err.causedBy(cause) 174 | 175 | expect(err.toJSONSafe()).toEqual({ 176 | meta: {} 177 | }) 178 | }) 179 | 180 | it('should omit fields in toJSONSafe()', () => { 181 | const cause = new Error('test caused by') 182 | const err = new BaseError('test message') 183 | err.causedBy(cause) 184 | 185 | expect(err.toJSONSafe(['name'])).toEqual({ 186 | meta: {}, 187 | name: undefined 188 | }) 189 | }) 190 | 191 | it('should omit fields in toJSONSafe() via config', () => { 192 | const err = new BaseError('test message', { 193 | toJSONSafeFieldsToOmit: ['errId'], 194 | omitEmptyMetadata: true 195 | }).withErrorId('test-id') 196 | 197 | const data = err.toJSONSafe() 198 | 199 | expect(data.errId).not.toBeDefined() 200 | expect(data.meta).not.toBeDefined() 201 | }) 202 | 203 | it('should call transformToJSONFn / transformToJSONSafeFn if defined', () => { 204 | const err = new BaseError('test message', { 205 | onPreToJSONData: data => { 206 | data.blah = 'test' 207 | return data 208 | }, 209 | onPreToJSONSafeData: data => { 210 | data.blah2 = 'test2' 211 | return data 212 | } 213 | }).withErrorId('test-id') 214 | 215 | const notSafe = err.toJSON() 216 | const safe = err.toJSONSafe() 217 | 218 | expect(notSafe.blah).toEqual('test') 219 | expect(safe.blah2).toEqual('test2') 220 | }) 221 | 222 | it('should update config', () => { 223 | const err = new BaseError('test message', { 224 | toJSONSafeFieldsToOmit: ['errId'] 225 | }).withErrorId('test-id') 226 | 227 | expect(err.toJSONSafe().errId).not.toBeDefined() 228 | 229 | err.setConfig({}) 230 | 231 | expect(err.toJSONSafe().errId).toBeDefined() 232 | }) 233 | 234 | it('should not throw if toJSON / toJSONSafe is not defined with an omit array', () => { 235 | const err = new BaseError('test message') 236 | 237 | expect(() => err.toJSON(null)).not.toThrow() 238 | expect(() => err.toJSONSafe(null)).not.toThrow() 239 | }) 240 | 241 | it('should default to an empty object if null is passed to the config option', () => { 242 | const err = new BaseError('test message', null) 243 | 244 | expect(err.getConfig()).toBeDefined() 245 | }) 246 | 247 | describe('append caused by message', () => { 248 | it('should append the caused by message to the main message', () => { 249 | const err = new BaseError('Root error', { 250 | appendWithErrorMessageFormat: ': %s' 251 | }) 252 | err.causedBy(new Error('Sub error')) 253 | expect(err.message).toBe('Root error: Sub error') 254 | expect(err.toJSON().message).toBe('Root error: Sub error') 255 | }) 256 | 257 | it('should append the caused by message to the main message with format', () => { 258 | const err = new BaseError('Root error: %s', { 259 | appendWithErrorMessageFormat: ': %s' 260 | }) 261 | err.formatMessage('Root cause') 262 | err.causedBy(new Error('Sub error')) 263 | expect(err.message).toBe('Root error: Root cause: Sub error') 264 | expect(err.toJSON().message).toBe('Root error: Root cause: Sub error') 265 | }) 266 | 267 | it('should append the caused by message to the main message with format reversed', () => { 268 | const err = new BaseError('Root error: %s', { 269 | appendWithErrorMessageFormat: ': %s' 270 | }) 271 | err.causedBy(new Error('Sub error')) 272 | err.formatMessage('Root cause') 273 | expect(err.message).toBe('Root error: Root cause: Sub error') 274 | expect(err.toJSON().message).toBe('Root error: Root cause: Sub error') 275 | }) 276 | }) 277 | 278 | describe('conversion', () => { 279 | it('should return false if onConvert is not defined', () => { 280 | const err = new BaseError('test') 281 | expect(err.hasOnConvertDefined()).toEqual(false) 282 | }) 283 | 284 | it('should return true if onConvert is defined', () => { 285 | const err = new BaseError('test', { 286 | onConvert: () => { 287 | return 'test' 288 | } 289 | }) 290 | 291 | expect(err.hasOnConvertDefined()).toEqual(true) 292 | }) 293 | 294 | it('should update the conversion fn', () => { 295 | const err = new BaseError('test') 296 | err.setOnConvert(() => { 297 | return 'test' 298 | }) 299 | expect(err.hasOnConvertDefined()).toEqual(true) 300 | }) 301 | }) 302 | 303 | describe('Deserialization', () => { 304 | it('throws if the data is not an object', () => { 305 | // @ts-ignore 306 | expect(() => BaseError.fromJSON('')).toThrowError() 307 | }) 308 | 309 | it('#copyDeserializationData - should work with empty data', () => { 310 | const err = new BaseError('test message') 311 | 312 | BaseError.copyDeserializationData(err, {}, {}) 313 | 314 | expect(err.toJSON()).toEqual( 315 | expect.objectContaining({ 316 | message: 'test message', 317 | meta: {}, 318 | name: 'BaseError' 319 | }) 320 | ) 321 | }) 322 | 323 | it('#copyDeserializationData - should work with metadata', () => { 324 | const err = new BaseError('test message') 325 | 326 | BaseError.copyDeserializationData( 327 | err, 328 | { 329 | meta: { 330 | safe: '123', 331 | unsafe: '456' 332 | } 333 | }, 334 | { 335 | safeMetadataFields: { 336 | safe: true 337 | } 338 | } 339 | ) 340 | 341 | expect(err.getMetadata()).toEqual({ 342 | unsafe: '456' 343 | }) 344 | 345 | expect(err.getSafeMetadata()).toEqual({ 346 | safe: '123' 347 | }) 348 | 349 | expect(err.toJSON()).toEqual( 350 | expect.objectContaining({ 351 | message: 'test message', 352 | name: 'BaseError' 353 | }) 354 | ) 355 | }) 356 | 357 | it('#copyDeserializationData - should copy data to an instance', () => { 358 | const err = new BaseError('test message') 359 | 360 | BaseError.copyDeserializationData( 361 | err, 362 | { 363 | errId: 'err-123', 364 | code: 'ERR_INT_500', 365 | subCode: 'DB_0001', 366 | message: 'test message', 367 | meta: { safe: 'test454', test: 'test123' }, 368 | name: 'BaseError', 369 | statusCode: 500, 370 | causedBy: 'test', 371 | stack: 'abcd' 372 | }, 373 | {} 374 | ) 375 | 376 | expect(err.toJSON()).toEqual( 377 | expect.objectContaining({ 378 | errId: 'err-123', 379 | code: 'ERR_INT_500', 380 | subCode: 'DB_0001', 381 | message: 'test message', 382 | meta: { safe: 'test454', test: 'test123' }, 383 | name: 'BaseError', 384 | statusCode: 500, 385 | causedBy: 'test' 386 | }) 387 | ) 388 | }) 389 | 390 | it('should deserialize an error without options', () => { 391 | const err = new BaseError('test message') 392 | .withErrorId('err-123') 393 | .withRequestId('req-123') 394 | .withErrorType('DATABASE_FAILURE') 395 | .withErrorCode('ERR_INT_500') 396 | .withErrorSubCode('DB_0001') 397 | .withStatusCode(500) 398 | .withLogLevel('error') 399 | .withMetadata({ 400 | test: 'test123' 401 | }) 402 | .withSafeMetadata({ 403 | safe: 'test454' 404 | }) 405 | .causedBy('test') 406 | 407 | const data = err.toJSON() 408 | const err2 = BaseError.fromJSON(data) 409 | 410 | expect(err2.getSafeMetadata()).toEqual({}) 411 | expect(err2.getMetadata()).toEqual({ 412 | test: 'test123', 413 | safe: 'test454' 414 | }) 415 | 416 | expect(err2.toJSON()).toEqual( 417 | expect.objectContaining({ 418 | errId: 'err-123', 419 | reqId: 'req-123', 420 | code: 'ERR_INT_500', 421 | logLevel: 'error', 422 | subCode: 'DB_0001', 423 | message: 'test message', 424 | meta: { safe: 'test454', test: 'test123' }, 425 | name: 'BaseError', 426 | statusCode: 500, 427 | causedBy: 'test' 428 | }) 429 | ) 430 | }) 431 | 432 | it('should deserialize an error with options', () => { 433 | const err = new BaseError('test message') 434 | .withErrorId('err-123') 435 | .withRequestId('req-123') 436 | .withErrorType('DATABASE_FAILURE') 437 | .withErrorCode('ERR_INT_500') 438 | .withErrorSubCode('DB_0001') 439 | .withStatusCode(500) 440 | .withLogLevel('error') 441 | .withMetadata({ 442 | test: 'test123' 443 | }) 444 | .withSafeMetadata({ 445 | safe: 'test454' 446 | }) 447 | .causedBy('test') 448 | 449 | const data = err.toJSON() 450 | const err2 = BaseError.fromJSON(data, { 451 | safeMetadataFields: { 452 | safe: true 453 | } 454 | }) 455 | 456 | expect(err2.getSafeMetadata()).toEqual({ 457 | safe: 'test454' 458 | }) 459 | 460 | expect(err2.toJSON()).toEqual( 461 | expect.objectContaining({ 462 | errId: 'err-123', 463 | reqId: 'req-123', 464 | logLevel: 'error', 465 | code: 'ERR_INT_500', 466 | subCode: 'DB_0001', 467 | message: 'test message', 468 | meta: { safe: 'test454', test: 'test123' }, 469 | name: 'BaseError', 470 | statusCode: 500, 471 | causedBy: 'test' 472 | }) 473 | ) 474 | }) 475 | 476 | it('should be able to override fromJSON()', () => { 477 | interface InternalServerErrorOpts extends DeserializeOpts {} 478 | 479 | class InternalServerError extends BaseError { 480 | // You can extend DeserializeOpts to add in additional options if you want 481 | static fromJSON ( 482 | data: Partial, 483 | opts: InternalServerErrorOpts 484 | ): InternalServerError { 485 | if (!opts) { 486 | opts = {} as InternalServerErrorOpts 487 | } 488 | 489 | if (typeof data === 'string') { 490 | throw new Error( 491 | `InternalServerError#fromJSON(): Data is not an object.` 492 | ) 493 | } 494 | 495 | let err = new InternalServerError(data.message) 496 | 497 | BaseError.copyDeserializationData< 498 | InternalServerError, 499 | InternalServerErrorOpts 500 | >(err, data, opts) 501 | 502 | return err 503 | } 504 | } 505 | 506 | const err = new InternalServerError('test message') 507 | .withErrorId('err-123') 508 | .withErrorType('DATABASE_FAILURE') 509 | .withErrorCode('ERR_INT_500') 510 | .withErrorSubCode('DB_0001') 511 | .withStatusCode(500) 512 | .withLogLevel('error') 513 | .withMetadata({ 514 | test: 'test123' 515 | }) 516 | .withSafeMetadata({ 517 | safe: 'test454' 518 | }) 519 | 520 | const data = err.toJSON() 521 | 522 | const err2 = InternalServerError.fromJSON(data, { 523 | safeMetadataFields: { 524 | safe: true 525 | } 526 | }) 527 | 528 | expect(err2.toJSON()).toEqual( 529 | expect.objectContaining({ 530 | errId: 'err-123', 531 | code: 'ERR_INT_500', 532 | subCode: 'DB_0001', 533 | message: 'test message', 534 | meta: { safe: 'test454', test: 'test123' }, 535 | name: 'InternalServerError', 536 | statusCode: 500 537 | }) 538 | ) 539 | }) 540 | }) 541 | }) 542 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # new-error 2 | 3 | [![NPM version](https://img.shields.io/npm/v/new-error.svg?style=flat-square)](https://www.npmjs.com/package/new-error) 4 | [![CircleCI](https://circleci.com/gh/theogravity/new-error.svg?style=svg)](https://circleci.com/gh/theogravity/new-error) 5 | ![built with typescript](https://camo.githubusercontent.com/92e9f7b1209bab9e3e9cd8cdf62f072a624da461/68747470733a2f2f666c61742e62616467656e2e6e65742f62616467652f4275696c74253230576974682f547970655363726970742f626c7565) 6 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 7 | 8 | A production-grade error creation library designed for Typescript. Useful for direct printing 9 | of errors to a client or for internal development / logs. 10 | 11 | - All created errors extend `Error` with additional methods added on. 12 | - Show errors that are safe for client / user-consumption vs internal only. 13 | - Create your own custom error types with custom messaging, status codes and metadata. 14 | * Errors can be created via a registry (recommended), or you can create your own error classes. 15 | - Attach an error to your error object to get the full error chain. 16 | - Selectively expose error metadata based on internal or external use. 17 | - Built-in auto-completion for Typescript when searching for registered error types. 18 | - 100% test coverage 19 | 20 | ![Generating an error with autocompletion](/autocomplete.jpg?raw=true "Title") 21 | 22 | # Table of Contents 23 | 24 | 25 | 26 | - [Motivation / Error handling use-cases](#motivation--error-handling-use-cases) 27 | - [Installation](#installation) 28 | - [Examples](#examples) 29 | - [With the error registry](#with-the-error-registry) 30 | - [Helper utilities](#helper-utilities) 31 | - [Auto-generate high level error properties](#auto-generate-high-level-error-properties) 32 | - [Configuration options](#configuration-options) 33 | - [Auto-generate low level error properties](#auto-generate-low-level-error-properties) 34 | - [Configuration options](#configuration-options-1) 35 | - [Class-based with low level errors without a registry](#class-based-with-low-level-errors-without-a-registry) 36 | - [Bare-bones class-based error](#bare-bones-class-based-error) 37 | - [Example Express Integration](#example-express-integration) 38 | - [Working with log levels](#working-with-log-levels) 39 | - [Error Registry API](#error-registry-api) 40 | - [Constructor](#constructor) 41 | - [Configuration options](#configuration-options-2) 42 | - [Example](#example) 43 | - [Child registry with context](#child-registry-with-context) 44 | - [Configuration options](#configuration-options-3) 45 | - [Example](#example-1) 46 | - [Creating errors](#creating-errors) 47 | - [Create a well-defined error](#create-a-well-defined-error) 48 | - [Create an error without a low-level error](#create-an-error-without-a-low-level-error) 49 | - [Specify a custom message](#specify-a-custom-message) 50 | - [Use the message property from the high level error if defined](#use-the-message-property-from-the-high-level-error-if-defined) 51 | - [Custom message not defined and high level error has no message property defined](#custom-message-not-defined-and-high-level-error-has-no-message-property-defined) 52 | - [Error creation handler](#error-creation-handler) 53 | - [`instanceOf` / comparisons](#instanceof--comparisons) 54 | - [Comparing a custom error](#comparing-a-custom-error) 55 | - [Native `instanceof`](#native-instanceof) 56 | - [Error API](#error-api) 57 | - [Constructor](#constructor-1) 58 | - [Configuration options](#configuration-options-4) 59 | - [Getters](#getters) 60 | - [Basic setters](#basic-setters) 61 | - [Static methods](#static-methods) 62 | - [Utility methods](#utility-methods) 63 | - [Set an error id](#set-an-error-id) 64 | - [Set a request id](#set-a-request-id) 65 | - [Attaching errors](#attaching-errors) 66 | - [Append the attached error message to the main error message](#append-the-attached-error-message-to-the-main-error-message) 67 | - [Format messages](#format-messages) 68 | - [Converting the error into another type](#converting-the-error-into-another-type) 69 | - [Apollo GraphQL example](#apollo-graphql-example) 70 | - [Adding metadata](#adding-metadata) 71 | - [Safe metadata](#safe-metadata) 72 | - [Internal metadata](#internal-metadata) 73 | - [Serializing errors](#serializing-errors) 74 | - [Safe serialization](#safe-serialization) 75 | - [Internal serialization](#internal-serialization) 76 | - [Post-processing handlers](#post-processing-handlers) 77 | - [Deserialization](#deserialization) 78 | - [Issues with deserialization](#issues-with-deserialization) 79 | - [Deserialization is not perfect](#deserialization-is-not-perfect) 80 | - [Potential security issues with deserialization](#potential-security-issues-with-deserialization) 81 | - [`ErrorRegistry#fromJSON()` method](#errorregistryfromjson-method) 82 | - [`static BaseError#fromJSON()` method](#static-baseerrorfromjson-method) 83 | - [Stand-alone instance-based deserialization](#stand-alone-instance-based-deserialization) 84 | - [Looking for production-grade env variable / configuration management?](#looking-for-production-grade-env-variable--configuration-management) 85 | 86 | 87 | 88 | # Motivation / Error handling use-cases 89 | 90 | The basic Javascript `Error` type is extremely bare bones - you can only specify a message. 91 | 92 | In a production-level application, I've experienced the following use-cases: 93 | 94 | - A developer should be able to add metadata to the error that may assist with troubleshooting. 95 | - A developer should be able to reference the original error. 96 | - Errors should be able to work with a logging framework. 97 | - Errors should be well-formed / have a defined structure that can be consumed / emitted for analytics and services. 98 | - Errors should be able to be cross-referenced in various systems via an identifier / error id. 99 | - Errors should not expose sensitive data to the end-user / client. 100 | - Errors that are exposed to the end-user / client should not reveal data that would expose system internals. 101 | - Error responses from an API service should follow a common format. 102 | - End-users / clients should be able to relay the error back to support; the relayed data should be enough for a developer to troubleshoot. 103 | - Client developers prefer a list of error codes to expect from an API service so they can properly handle errors. 104 | - You want to classify the types of errors that your application is emitting in your metrics / analytics tool. 105 | 106 | `new-error` was built with these use-cases in mind. 107 | 108 | # Installation 109 | 110 | `$ npm i new-error --save` 111 | 112 | # Examples 113 | 114 | - Define a set of high level errors 115 | * Common high level error types could be 4xx/5xx HTTP codes 116 | - Define a set of low level errors 117 | * Think of low level errors as a fine-grained sub-code/category to a high level error 118 | - Initialize the error registry with the errors 119 | 120 | ## With the error registry 121 | 122 | The error registry is the fastest way to define and create errors. 123 | 124 | ```typescript 125 | // This is a working example 126 | import { ErrorRegistry } from 'new-error' 127 | 128 | // Define high level errors 129 | // Do *not* assign a Typescript type to the object 130 | // or IDE autocompletion will not work! 131 | const errors = { 132 | INTERNAL_SERVER_ERROR: { 133 | /** 134 | * The class name of the generated error 135 | */ 136 | className: 'InternalServerError', 137 | /** 138 | * A user-friendly code to show to a client. 139 | */ 140 | code: 'ERR_INT_500', 141 | /** 142 | * (optional) Protocol-specific status code, such as an HTTP status code. Used as the 143 | * default if a Low Level Error status code is not defined. 144 | */ 145 | statusCode: 500, 146 | /** 147 | * (optional) Log level string / number to associate with this error. 148 | * Useful if you want to use your logging system to log the error but 149 | * assign a different log level for it. Used as the default if a 150 | * Low Level log level is not defined. 151 | */ 152 | logLevel: 'error', 153 | /** 154 | * (optional) Callback function to call when calling BaseError#convert(). 155 | * 156 | * (baseError) => any type 157 | * 158 | * - If not defined, will return itself when convert() is called 159 | * - If defined in HighLevelError, the HighLevelError definition takes priority 160 | */ 161 | onConvert: (err) => { return err }, 162 | /** 163 | * (optional) Full description of the error. Used only when BaseError#newBareError() is called 164 | * without the message parameter. 165 | * 166 | * sprintf() flags can be applied to customize it. 167 | * @see https://www.npmjs.com/package/sprintf-js 168 | */ 169 | message: 'Internal server error' 170 | } 171 | } 172 | 173 | // Define low-level errors 174 | // Do *not* assign a Typescript type to the object 175 | // or IDE autocompletion will not work! 176 | const errorSubCodes = { 177 | // 'type' of error 178 | DATABASE_FAILURE: { 179 | /** 180 | * Full description of the error. sprintf() flags can be applied 181 | * to customize it. 182 | * @see https://www.npmjs.com/package/sprintf-js 183 | */ 184 | message: 'There was a database failure, SQL err code %s', 185 | /** 186 | * (optional) A user-friendly code to show to a client. 187 | */ 188 | subCode: 'DB_0001', 189 | /** 190 | * (optional) Protocol-specific status code, such as an HTTP status code. 191 | */ 192 | statusCode: 500, 193 | /** 194 | * (optional) Log level string / number to associate with this error. 195 | * Useful if you want to use your logging system to log the error but 196 | * assign a different log level for it. 197 | */ 198 | logLevel: 'error', 199 | /** 200 | * (optional) Callback function to call when calling BaseError#convert(). 201 | * 202 | * (baseError) => any type 203 | * 204 | * - If not defined, will return itself when convert() is called 205 | * - This definition takes priority if HighLevelError#onConvert is defined 206 | */ 207 | onConvert: (err) => { return err } 208 | } 209 | } 210 | 211 | // Create the error registry by registering your errors and codes 212 | // you will want to memoize this as you will be using the 213 | // reference throughout your application 214 | const errRegistry = new ErrorRegistry(errors, errorSubCodes) 215 | 216 | // Create an instance of InternalServerError 217 | // Typescript autocomplete should show the available definitions as you type the error names 218 | // and type check will ensure that the values are valid 219 | const err = errRegistry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 220 | .withErrorId('err-1234') 221 | .formatMessage('SQL_1234') 222 | 223 | console.log(err.toJSON()) 224 | ``` 225 | 226 | Produces: 227 | 228 | (You can omit fields you do not need - see usage section below.) 229 | 230 | ``` 231 | { 232 | errId: 'err-1234', 233 | name: 'InternalServerError', 234 | code: 'ERR_INT_500', 235 | message: 'There was a database failure, SQL err code SQL_1234', 236 | type: 'DATABASE_FAILURE', 237 | subCode: 'DB_0001', 238 | statusCode: 500, 239 | meta: {}, 240 | stack: 'InternalServerError: There was a database failure, SQL err code %s\n' + 241 | ' at ErrorRegistry.newError (new-error/src/ErrorRegistry.ts:128:12)\n' + 242 | ' at Object. (new-error/src/test.ts:55:25)\n' + 243 | ' at Module._compile (internal/modules/cjs/loader.js:1158:30)\n' + 244 | ' at Module._compile (new-error/node_modules/source-map-support/source-map-support.js:541:25)\n' + 245 | ' at Module.m._compile (/private/var/folders/mx/b54hc2lj3fbfsndkv4xmz8d80000gn/T/ts-node-dev-hook-20649714243977457.js:57:25)\n' + 246 | ' at Module._extensions..js (internal/modules/cjs/loader.js:1178:10)\n' + 247 | ' at require.extensions. (/private/var/folders/mx/b54hc2lj3fbfsndkv4xmz8d80000gn/T/ts-node-dev-hook-20649714243977457.js:59:14)\n' + 248 | ' at Object.nodeDevHook [as .ts] (new-error/node_modules/ts-node-dev/lib/hook.js:61:7)\n' + 249 | ' at Module.load (internal/modules/cjs/loader.js:1002:32)\n' + 250 | ' at Function.Module._load (internal/modules/cjs/loader.js:901:14)' 251 | } 252 | ``` 253 | 254 | ### Helper utilities 255 | 256 | #### Auto-generate high level error properties 257 | 258 | `generateHighLevelErrors(errorDefs, options: GenerateHighLevelErrorOpts)` 259 | 260 | If you find yourself doing the following pattern: 261 | 262 | ```ts 263 | const errors = { 264 | INTERNAL_SERVER_ERROR: { 265 | className: 'InternalServerError', // pascal case'd property name 266 | code: 'INTERNAL_SERVER_ERROR', // same as the property name 267 | statusCode: 500 268 | } 269 | } 270 | ``` 271 | 272 | You can use the utility method to do it instead: 273 | 274 | ```ts 275 | import { generateHighLevelErrors } from 'new-error' 276 | 277 | const errors = generateHighLevelErrors({ 278 | INTERNAL_SERVER_ERROR: { 279 | statusCode: 500 280 | } 281 | }) 282 | ``` 283 | 284 | - If a `className` or `code` is already defined, it will not overwrite it 285 | 286 | ##### Configuration options 287 | 288 | ```ts 289 | interface GenerateHighLevelErrorOpts { 290 | /** 291 | * Disable to not generate the class name based on the property name if the className is not defined. 292 | */ 293 | disableGenerateClassName?: boolean 294 | /** 295 | * Disable to not generate the error code based on the property name if the code is not defined. 296 | */ 297 | disableGenerateCode?: boolean 298 | } 299 | ``` 300 | 301 | #### Auto-generate low level error properties 302 | 303 | `generateLowLevelErrors(errorDefs, options: GenerateLowLevelErrorOpts)` 304 | 305 | If you find yourself doing the following pattern: 306 | 307 | ```ts 308 | const errors = { 309 | DATABASE_FAILURE: { 310 | subCode: 'DATABASE_FAILURE', // same as the property name 311 | message: 'Database failure' 312 | } 313 | } 314 | ``` 315 | 316 | You can use the utility method to do it instead: 317 | 318 | ```ts 319 | import { generateLowLevelErrors } from 'new-error' 320 | 321 | const errors = generateLowLevelErrors({ 322 | DATABASE_FAILURE: { 323 | message: 'Database failure' 324 | } 325 | }) 326 | ``` 327 | 328 | - If a `subCode` is already defined, it will not overwrite it 329 | 330 | ##### Configuration options 331 | 332 | ```ts 333 | interface GenerateLowLevelErrorOpts { 334 | /** 335 | * Disable to not generate the error code based on the property name if the subCode is not defined. 336 | */ 337 | disableGenerateSubCode?: boolean 338 | } 339 | ``` 340 | 341 | ## Class-based with low level errors without a registry 342 | 343 | You can create concrete error classes by extending the `BaseRegistryError` class, which 344 | extends the `BaseError` class. 345 | 346 | The registry example can be also written as: 347 | 348 | ```typescript 349 | import { BaseRegistryError, LowLevelError } from 'new-error' 350 | 351 | class InternalServerError extends BaseRegistryError { 352 | constructor (errDef: LowLevelError) { 353 | super({ 354 | code: 'ERR_INT_500', 355 | statusCode: 500 356 | }, errDef) 357 | } 358 | } 359 | 360 | const err = new InternalServerError({ 361 | type: 'DATABASE_FAILURE', 362 | message: 'There was a database failure, SQL err code %s', 363 | subCode: 'DB_0001', 364 | statusCode: 500, 365 | logLevel: 'error' 366 | }) 367 | 368 | console.log(err.formatMessage('SQL_1234').toJSON()) 369 | ``` 370 | 371 | ## Bare-bones class-based error 372 | 373 | If you want a native-style `Error`, you can use `BaseError`. 374 | 375 | The registry example can be written as: 376 | 377 | ```typescript 378 | import { BaseError } from 'new-error' 379 | 380 | class InternalServerError extends BaseError {} 381 | 382 | const err = new InternalServerError('There was a database failure, SQL err code %s') 383 | // calling these methods are optional 384 | .withErrorType('DATABASE_FAILURE') 385 | .withErrorCode('ERR_INT_500') 386 | .withErrorSubCode('DB_0001') 387 | .withStatusCode(500) 388 | .withLogLevel('error') 389 | 390 | console.log(err.formatMessage('SQL_1234').toJSON()) 391 | ``` 392 | 393 | # Example Express Integration 394 | 395 | ```typescript 396 | import express from 'express' 397 | import { ErrorRegistry, BaseError } from 'new-error' 398 | const app = express() 399 | const port = 3000 400 | 401 | const errors = { 402 | INTERNAL_SERVER_ERROR: { 403 | className: 'InternalServerError', 404 | code: 'ERR_INT_500', 405 | statusCode: 500 406 | } 407 | } 408 | 409 | const errorSubCodes = { 410 | DATABASE_FAILURE: { 411 | message: 'There was a database failure.', 412 | subCode: 'DB_0001', 413 | statusCode: 500 414 | } 415 | } 416 | 417 | const errRegistry = new ErrorRegistry(errors, errorSubCodes) 418 | 419 | // middleware definition 420 | app.get('/', async (req, res, next) => { 421 | try { 422 | // simulate a failure 423 | throw new Error('SQL issue') 424 | } catch (e) { 425 | const err = errRegistry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 426 | err.causedBy(err) 427 | // errors must be passed to next() 428 | // to be caught when using an async middleware 429 | return next(err) 430 | } 431 | }) 432 | 433 | // catch errors 434 | app.use((err, req, res, next) => { 435 | // error was sent from middleware 436 | if (err) { 437 | // check if the error is a generated one 438 | if (err instanceof BaseError) { 439 | // generate an error id 440 | // you'll want to use a library like 'nanoid' instead 441 | // this is just an example 442 | err.withErrorId(Math.random().toString(36).slice(2)) 443 | 444 | // log the error 445 | // the "null, 2" options formats the error into a readable structure 446 | console.error(JSON.stringify(err.toJSON(), null, 2)) 447 | 448 | // get the status code, if the status code is not defined, default to 500 449 | res.status(err.getStatusCode() ?? 500) 450 | // spit out the error to the client 451 | return res.json({ 452 | err: err.toJSONSafe() 453 | }) 454 | } 455 | 456 | // You'll need to modify code below to best fit your use-case 457 | // err.message could potentially expose system internals 458 | return res.json({ 459 | err: { 460 | message: err.message 461 | } 462 | }) 463 | } 464 | 465 | // no error, proceed 466 | next() 467 | }) 468 | 469 | app.listen(port, () => console.log(`Example app listening at http://localhost:${port}`)) 470 | ``` 471 | 472 | If you visit `http://localhost:3000`, you'll get a 500 status code, and the following response: 473 | 474 | ``` 475 | {"err": {"errId": "xd0v1szkziq", code":"ERR_INT_500","subCode":"DB_0001","statusCode":500,"meta":{}}} 476 | ``` 477 | 478 | # Working with log levels 479 | 480 | You might want to use a different log level when logging common errors, such as validation errors. 481 | 482 | ```typescript 483 | import { ErrorRegistry } from 'new-error' 484 | 485 | const errors = { 486 | VALIDATION_ERROR: { 487 | className: 'ValidationError', 488 | code: 'VALIDATION_ERROR', 489 | statusCode: 400, 490 | // probably don't want to log every validation error 491 | // in production since these errors tend to happen frequently 492 | // and would pollute the logs 493 | logLevel: 'debug' 494 | } 495 | } 496 | 497 | const errorSubCodes = { 498 | MISSING_FORM_FIELDS: { 499 | message: 'Form submission data is missing fields', 500 | subCode: 'MISSING_FORM_FIELDS', 501 | statusCode: 400 502 | } 503 | } 504 | 505 | const errRegistry = new ErrorRegistry(errors, errorSubCodes) 506 | 507 | // some part of the application throws the error 508 | const err = errRegistry.newError('VALIDATION_ERROR', 'MISSING_FORM_FIELDS') 509 | 510 | // another part of the application catches the error 511 | if (err.getLogLevel() === 'debug') { 512 | console.debug(JSON.stringify(err.toJSON(), null, 2)) 513 | } else { 514 | console.error(JSON.stringify(err.toJSON(), null, 2)) 515 | } 516 | ``` 517 | 518 | # Error Registry API 519 | 520 | The `ErrorRegistry` is responsible for the registration and creation of errors. 521 | 522 | ## Constructor 523 | 524 | `new ErrorRegistry(highLvErrors, lowLvErrors, config = {})` 525 | 526 | ### Configuration options 527 | 528 | ```ts 529 | interface IErrorRegistryConfig { 530 | /** 531 | * Options when creating a new BaseError 532 | */ 533 | baseErrorConfig?: IBaseErrorConfig 534 | /** 535 | * Handler to modify the created error when newError / newBareError is called 536 | */ 537 | onCreateError?: (err: BaseRegistryError) => void 538 | } 539 | ``` 540 | 541 | ### Example 542 | 543 | ```ts 544 | const errRegistry = new ErrorRegistry(errors, errorSubCodes, { 545 | // Config for all BaseErrors created from the registry 546 | baseErrorConfig: { 547 | // Remove the `meta` field if there is no data present for `toJSON` / `toJSONSafe` 548 | omitEmptyMetadata: true 549 | } 550 | }) 551 | ``` 552 | 553 | ## Child registry with context 554 | 555 | `ErrorRegistry#withContext(context: IErrorRegistryContextConfig)` 556 | 557 | You can create a child registry that adds context for all new errors created. This is useful if 558 | your body of code throws multiple errors and you want to include the same metadata for each one 559 | without repeating yourself. 560 | 561 | - All property **references** are copied to the child registry from the parent. This keeps memory usage 562 | low as the references are re-used vs a complete clone of the data. 563 | - Because all properties are copied over, the child registry will execute any handlers / config options 564 | the parent has when creating new errors. 565 | 566 | ### Configuration options 567 | 568 | ```typescript 569 | export interface IErrorRegistryContextConfig { 570 | /** 571 | * Metadata to include for each new error created by the registry 572 | */ 573 | metadata?: Record 574 | /** 575 | * Safe metadata to include for each new error created by the registry 576 | */ 577 | safeMetadata?: Record 578 | } 579 | ``` 580 | 581 | ### Example 582 | 583 | ```typescript 584 | const childRegistry = errRegistry.withContext({ 585 | metadata: { 586 | contextA: 'context-a' 587 | }, 588 | safeMetadata: { 589 | contextB: 'context-b' 590 | } 591 | }) 592 | 593 | const err = childRegistry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 594 | ``` 595 | 596 | If we do `err.toJSON()`, we should get the following output: 597 | 598 | ```json5 599 | { 600 | name: 'InternalServerError', 601 | code: 'INT_ERR', 602 | message: 'There is an issue with the database', 603 | type: 'DATABASE_FAILURE', 604 | subCode: 'DB_ERR', 605 | statusCode: 500, 606 | // err.toJSONSafe() would exclude contextA 607 | meta: { contextA: 'context-a', contextB: 'context-b' }, 608 | stack: '...' 609 | } 610 | ``` 611 | 612 | We can also append data: 613 | 614 | ```typescript 615 | const err = childRegistry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 616 | .withMetadata({ 617 | moreMeta: 'data' 618 | }) 619 | ``` 620 | 621 | If we do `err.toJSON()`, we should get the following output: 622 | 623 | ```json5 624 | { 625 | name: 'InternalServerError', 626 | code: 'INT_ERR', 627 | message: 'There is an issue with the database', 628 | type: 'DATABASE_FAILURE', 629 | subCode: 'DB_ERR', 630 | statusCode: 500, 631 | // err.toJSONSafe() would exclude contextA and moreMeta 632 | meta: { contextA: 'context-a', contextB: 'context-b', moreMeta: 'data' }, 633 | stack: '...' 634 | } 635 | ``` 636 | 637 | ## Creating errors 638 | 639 | Errors generated by the registry extends `BaseError`. 640 | 641 | ### Create a well-defined error 642 | 643 | Method: `ErrorRegistry#newError(highLevelErrorName, LowLevelErrorName)` 644 | 645 | This is the method you should generally use as you are forced to use your 646 | well-defined high and low level error definitions. This allows for consistency 647 | in how errors are defined and thrown. 648 | 649 | ```typescript 650 | // Creates an InternalServerError error with a DATABASE_FAILURE code and corresponding 651 | // message and status code 652 | const err = errRegistry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 653 | ``` 654 | 655 | ### Create an error without a low-level error 656 | 657 | Method: `ErrorRegistry#newBareError(highLevelErrorName, [message])` 658 | 659 | This method does not include a low level error code, and allows direct specification of an 660 | error message. 661 | 662 | #### Specify a custom message 663 | 664 | ```typescript 665 | // Creates an InternalServerError error with a custom message 666 | const err = errRegistry.newBareError('INTERNAL_SERVER_ERROR', 'An internal server error has occured.') 667 | ``` 668 | 669 | #### Use the message property from the high level error if defined 670 | 671 | ```typescript 672 | const errors = { 673 | AUTH_REQUIRED: { 674 | className: 'AuthRequired', 675 | code: 'AUTH_REQ', 676 | message: 'Auth required' 677 | } 678 | } 679 | 680 | // Creates an AuthRequired error with a the 'Auth Required' message 681 | const err = errRegistry.newBareError('AUTH_REQUIRED') 682 | ``` 683 | 684 | #### Custom message not defined and high level error has no message property defined 685 | 686 | The error will use the code as the default. 687 | 688 | ```typescript 689 | const errors = { 690 | DB_ERROR: { 691 | className: 'DatabaseError', 692 | code: 'DB_ERR' 693 | } 694 | } 695 | 696 | // Creates an AuthRequired error with 'DB_ERR' as the message 697 | const err = errRegistry.newBareError('DB_ERROR') 698 | ``` 699 | 700 | ### Error creation handler 701 | 702 | If you want all errors created from the registry to have defined properties, you can use the `onCreateError` config option to modify the created error. 703 | 704 | For example, if you want to create an error id for each new error: 705 | 706 | ```ts 707 | const errRegistry = new ErrorRegistry(errors, errorSubCodes, { 708 | onCreateError: (err) => { 709 | err.withErrorId('test-id') 710 | } 711 | }) 712 | 713 | // the err should have 'test-id' set for the error id 714 | const err = errRegistry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 715 | ``` 716 | 717 | ## `instanceOf` / comparisons 718 | 719 | ### Comparing a custom error 720 | 721 | Method: `ErrorRegistry#instanceOf(classInstance, highLevelErrorName)` 722 | 723 | Performs an `instanceof` operation against a custom error. 724 | 725 | ```typescript 726 | // creates an InternalServerError error instance 727 | const err = errRegistry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 728 | 729 | if (errRegistry.instanceOf(err, 'INTERNAL_SERVER_ERROR')) { 730 | // resolves to true since err is an InternalServerError instance 731 | } 732 | ``` 733 | 734 | ### Native `instanceof` 735 | 736 | You can also check if the error is custom-built using this check: 737 | 738 | ```typescript 739 | import { BaseError } from 'new-error' 740 | 741 | function handleError(err) { 742 | if (err instanceof BaseError) { 743 | // err is a custom error 744 | } 745 | } 746 | ``` 747 | 748 | # Error API 749 | 750 | Except for the getter and serialization methods, all other methods are chainable. 751 | 752 | Generated errors extend the `BaseError` class, which extends `Error`. 753 | 754 | ## Constructor 755 | 756 | `new BaseError(message: string, config: IBaseErrorConfig: IBaseErrorConfig = {})` 757 | 758 | - `message`: The error message you would use in `new Error(message)` 759 | 760 | ### Configuration options 761 | 762 | ```ts 763 | interface IBaseErrorConfig { 764 | /** 765 | * A list of fields to always omit when calling toJSON 766 | */ 767 | toJSONFieldsToOmit?: string[] 768 | /** 769 | * A list of fields to always omit when calling toJSONSafe 770 | */ 771 | toJSONSafeFieldsToOmit?: string[] 772 | /** 773 | * If the metadata has no data defined, remove the `meta` property on `toJSON` / `toJSONSafe`. 774 | */ 775 | omitEmptyMetadata?: boolean 776 | /** 777 | * A function to run against the computed data when calling `toJSON`. This is called prior 778 | * to field omission. If defined, must return the data back. 779 | */ 780 | onPreToJSONData?: (data: Partial) => Partial 781 | /** 782 | * A function to run against the computed safe data when calling `toJSONSafe`. This is called 783 | * prior to field omission. If defined, must return the data back. 784 | */ 785 | onPreToJSONSafeData?: (data: Partial) => Partial 786 | /** 787 | * A callback function to call when calling BaseError#convert(). This allows for user-defined conversion 788 | * of the BaseError into some other type (such as a Apollo GraphQL error type). 789 | * 790 | * (baseError) => any type 791 | */ 792 | onConvert?: (err: E) => any 793 | /** 794 | * If defined, will append the `.message` value when calling causedBy() after the main error message. 795 | * Useful for frameworks like Jest where it will not print the caused by data. 796 | * To define the format of the appended message, use '%s' for the message value. 797 | * 798 | * Ex: ", caused by: %s" 799 | */ 800 | appendWithErrorMessageFormat?: string 801 | } 802 | ``` 803 | 804 | ## Getters 805 | 806 | The following getters are included with the standard `Error` properties and methods: 807 | 808 | - `BaseError#getErrorId()` 809 | - `BaseError#getRequestId()` 810 | - `BaseError#getErrorName()` 811 | - `BaseError#getCode()` 812 | - `BaseError#getErrorType()` 813 | - `BaseError#getSubCode()` 814 | - `BaseError#getStatusCode()` 815 | - `BaseError#getCausedBy()` 816 | - `BaseError#getMetadata()` 817 | - `BaseError#getSafeMetadata()` 818 | - `BaseError#getLogLevel()` 819 | - `BaseError#getConfig()` 820 | 821 | ## Basic setters 822 | 823 | If you use the registry, you should not need to us these setters as the registry 824 | sets the values already. 825 | 826 | - `BaseError#withErrorType(type: string): this` 827 | - `BaseError#withErrorCode(code: string | number): this` 828 | - `BaseError#withErrorSubCode(code: string | number): this` 829 | - `BaseError#withLogLevel(level: string | number): this` 830 | - `BaseError#setConfig(config: IBaseErrorConfig): void` 831 | - `BaseError#setOnConvert((err: E) => any): void` 832 | 833 | ## Static methods 834 | 835 | - `static BaseError#fromJSON(data: object, options?: object): BaseError` 836 | 837 | ## Utility methods 838 | 839 | - `BaseError#convert() : E` 840 | - `BaseError#hasOnConvertDefined(): boolean` 841 | 842 | ## Set an error id 843 | 844 | Method: `BaseError#withErrorId(errId: string)` 845 | 846 | Attaches an id to the error. Useful if you want to display an error id to a client / end-user 847 | and want to cross-reference that id in an internal logging system for easier troubleshooting. 848 | 849 | For example, you might want to use [`nanoid`](https://github.com/ai/nanoid) to generate ids for errors. 850 | 851 | ```typescript 852 | import { nanoid } from 'nanoid' 853 | 854 | err.withErrorId(nanoid()) 855 | 856 | // In your logging system, log the error, which will include the error id 857 | logger.error(err.toJSON()) 858 | 859 | // expose the error to the client via err.toJSONSafe() or err.getErrorId(), which 860 | // will also include the error id - an end-user can reference this id to 861 | // support for troubleshooting 862 | ``` 863 | 864 | ## Set a request id 865 | 866 | Method: `BaseError#withRequestId(reqId: string)` 867 | 868 | Attaches request id to the error. Useful if you want to display the request id to a client / end-user 869 | and want to cross-reference that id in an internal logging system for easier troubleshooting. 870 | 871 | For example, you might want to use [`nanoid`](https://github.com/ai/nanoid) to generate ids. 872 | 873 | ```typescript 874 | import { nanoid } from 'nanoid' 875 | 876 | err.withRequestId(nanoid()) 877 | 878 | // In your logging system, log the error, which will include the error id 879 | logger.error(err.toJSON()) 880 | 881 | // expose the error to the client via err.toJSONSafe() or err.getRequestId(), which 882 | // will also include the error id - an end-user can reference this id to 883 | // support for troubleshooting 884 | ``` 885 | 886 | ## Attaching errors 887 | 888 | Method: `BaseError#causedBy(err: any)` 889 | 890 | You can attach another error to the error. 891 | 892 | ```typescript 893 | const externalError = new Error('Some thrown error') 894 | err.causedBy(externalError) 895 | ``` 896 | 897 | ### Append the attached error message to the main error message 898 | 899 | If the config option `appendWithErrorMessageFormat` is defined, and the error sent into `causedBy` 900 | contains a `message` property, then the caused by error message will be appended to the main error message. 901 | 902 | Useful if you find yourself applying this pattern to expose the attached error message: 903 | 904 | ```typescript 905 | const thrownErrorFromApp = new Error('Duplicate key error') 906 | const err = new BaseError('Internal server error: %s'); 907 | err.causedBy(thrownErrorFromApp) 908 | err.formatMessage(thrownErrorFromApp.message); 909 | ``` 910 | 911 | This is also useful for test frameworks like `jest` where it will only print out the main error message 912 | and not any properties attached to the error. 913 | 914 | ```typescript 915 | // only enable for testing envs 916 | const IS_TEST_ENV = process.env.NODE_ENV === 'test'; 917 | const err = new BaseError('Internal server error', { 918 | // %s is the attached error message 919 | appendWithErrorMessageFormat: IS_TEST_ENV ? ': %s' : null 920 | }) 921 | 922 | err.causedBy(new Error('Duplicate key')) 923 | 924 | // prints out "Internal server error: Duplicate key" 925 | console.log(err.message) 926 | ``` 927 | 928 | ```typescript 929 | // formatted messages also work with this 930 | const IS_TEST_ENV = process.env.NODE_ENV === 'test'; 931 | const err = new BaseError('Internal server error: %s', { 932 | appendWithErrorMessageFormat: IS_TEST_ENV ? '===> %s' : null 933 | }) 934 | 935 | // formatMessage / causedBy can be called in any order 936 | err.formatMessage('Hello') 937 | err.causedBy(new Error('Duplicate key')) 938 | 939 | // prints out "Internal server error: Hello ===> Duplicate key" 940 | console.log(err.message) 941 | ``` 942 | 943 | **It is not recommended that `appendWithErrorMessageFormat` is defined in a production environment 944 | as the `causedBy` error messages tend to be system-level messages that could be exposed to clients 945 | if the error is being thrown back to the client**. 946 | 947 | 948 | ## Format messages 949 | 950 | Method: `BaseError#formatMessage(...formatParams)` 951 | 952 | See the [`sprintf-js`](https://www.npmjs.com/package/sprintf-js) package for usage. 953 | 954 | ```typescript 955 | // specify the database specific error code 956 | // Transforms the message to: 957 | // 'There was a database failure, SQL err code %s' -> 958 | // 'There was a database failure, SQL err code SQL_ERR_1234', 959 | err.formatMessage('SQL_ERR_1234') 960 | ``` 961 | 962 | The message can be accessed via the `.message` property. 963 | 964 | ## Converting the error into another type 965 | 966 | Method: `BaseError#convert() : T` 967 | 968 | This is useful if you need to convert the error into another type. This type can be another error or some other data type. 969 | 970 | ### Apollo GraphQL example 971 | 972 | For example, Apollo GraphQL prefers that any errors thrown from a GQL endpoint is an error that extends [`ApolloError`](https://www.apollographql.com/docs/apollo-server/data/errors/). 973 | 974 | You might find yourself doing the following pattern if your resolver happens to throw a `BaseError`: 975 | 976 | ```ts 977 | import { GraphQLError } from 'graphql'; 978 | import { BaseError } from 'new-error'; 979 | import { ApolloError, ForbiddenError } from 'apollo-server'; 980 | 981 | const server = new ApolloServer({ 982 | typeDefs, 983 | resolvers, 984 | formatError: (err: GraphQLError) => { 985 | const origError = error.originalError; 986 | 987 | if (origError instanceof BaseError) { 988 | // re-map the BaseError into an Apollo error type 989 | switch(origError.getCode()) { 990 | case 'PERMISSION_REQUIRED': 991 | return new ForbiddenError(err.message) 992 | default: 993 | return new ApolloError(err.message) 994 | } 995 | } 996 | 997 | return err; 998 | }, 999 | }); 1000 | ``` 1001 | 1002 | Trying to switch for every single code / subcode can be cumbersome. 1003 | 1004 | Instead of using this pattern, do the following so that your conversions can remain in one place: 1005 | 1006 | ```ts 1007 | import { GraphQLError } from 'graphql'; 1008 | import { BaseError, ErrorRegistry } from 'new-error'; 1009 | import { ApolloError, ForbiddenError } from 'apollo-server'; 1010 | 1011 | const errors = { 1012 | PERMISSION_REQUIRED: { 1013 | className: 'PermissionRequiredError', 1014 | code: 'PERMISSION_REQUIRED', 1015 | // Define a conversion function that is called when BaseError#convert() is called 1016 | // error is the BaseError 1017 | onConvert: (error) => { 1018 | return new ForbiddenError(error.message) 1019 | } 1020 | }, 1021 | AUTH_REQUIRED: { 1022 | className: 'AuthRequiredError', 1023 | code: 'AUTH_REQUIRED' 1024 | } 1025 | } 1026 | 1027 | const errorSubCodes = { 1028 | ADMIN_PANEL_RESTRICTED: { 1029 | message: 'Access scope required: admin', 1030 | onConvert: (error) => { 1031 | return new ForbiddenError('Admin required') 1032 | } 1033 | }, 1034 | EDITOR_SECTION_RESTRICTED: { 1035 | message: 'Access scope required: editor', 1036 | } 1037 | } 1038 | 1039 | const errRegistry = new ErrorRegistry(errors, errorSubCodes, { 1040 | onCreateError: (err) => { 1041 | // if an onConvert handler has not been defined, default to ApolloError 1042 | if (!err.hasOnConvertDefined()) { 1043 | err.setOnConvert((err) => { 1044 | if (process.env.NODE_ENV !== 'production') { 1045 | // return full error details 1046 | return new ApolloError(err.message, err.getCode(), err.toJSON()); 1047 | } 1048 | 1049 | // in production, we don't want to expose codes that are internal 1050 | return new ApolloError('Internal server error', 'INTERNAL_SERVER_ERROR', { 1051 | errId: err.getErrorId(), 1052 | }); 1053 | }); 1054 | } 1055 | } 1056 | }) 1057 | 1058 | const server = new ApolloServer({ 1059 | typeDefs, 1060 | resolvers, 1061 | // errors thrown from the resolvers come here 1062 | formatError: (err: GraphQLError) => { 1063 | const origError = error.originalError; 1064 | 1065 | if (origError instanceof BaseError) { 1066 | // log out the original error 1067 | console.log(origError.toJSON()) 1068 | 1069 | // Convert out to an Apollo error type 1070 | return origError.convert() 1071 | } 1072 | 1073 | // log out the apollo error 1074 | // you would probably want to see if you can convert this 1075 | // to a BaseError type in your code where this may be thrown from 1076 | console.log(err) 1077 | 1078 | return err; 1079 | }, 1080 | }); 1081 | 1082 | const resolvers = { 1083 | Query: { 1084 | adminSettings(parent, args, context, info) { 1085 | // err.convert() will call onConvert() of ADMIN_PANEL_RESTRICTED (low level defs have higher priority) 1086 | throw errRegistry.newError('PERMISSION_REQUIRED', 'ADMIN_PANEL_RESTRICTED') 1087 | }, 1088 | editorSettings(parent, args, context, info) { 1089 | // err.convert() will call onConvert() of PERMISSION_REQUIRED since EDITOR_SECTION_RESTRICTED does not 1090 | // have the onConvert defined 1091 | throw errRegistry.newError('PERMISSION_REQUIRED', 'EDITOR_SECTION_RESTRICTED') 1092 | }, 1093 | checkAuth(parent, args, context, info) { 1094 | // err.convert() will return ApolloError from onCreateError() since onConvert() is not defined for either AUTH_REQUIRED or EDITOR_SECTION_RESTRICTED 1095 | throw errRegistry.newError('AUTH_REQUIRED', 'EDITOR_SECTION_RESTRICTED') 1096 | }, 1097 | checkAuth2(parent, args, context, info) { 1098 | // err.convert() will return ApolloError from onCreateError() since onConvert() is not defined for either AUTH_REQUIRED 1099 | throw errRegistry.newBareError('AUTH_REQUIRED', 'Some error message') 1100 | }, 1101 | permRequired(parent, args, context, info) { 1102 | // err.convert() will call onConvert() of PERMISSION_REQUIRED 1103 | throw errRegistry.newBareError('PERMISSION_REQUIRED', 'Some error message') 1104 | } 1105 | } 1106 | } 1107 | ``` 1108 | 1109 | ## Adding metadata 1110 | 1111 | ### Safe metadata 1112 | 1113 | Method: `BaseError#withSafeMetadata(data = {})` 1114 | 1115 | Safe metadata would be any kind of data that you would be ok with exposing to a client, like an 1116 | HTTP response. 1117 | 1118 | ```typescript 1119 | err.withSafeMetadata({ 1120 | errorId: 'err-12345', 1121 | moreData: 1234 1122 | }) 1123 | // can be chained to append more data 1124 | .withSafeMetadata({ 1125 | requestId: 'req-12345' 1126 | }) 1127 | ``` 1128 | 1129 | This can also be written as: 1130 | 1131 | ```typescript 1132 | err.withSafeMetadata({ 1133 | errorId: 'err-12345', 1134 | moreData: 1234 1135 | }) 1136 | 1137 | // This will append requestId to the metadata 1138 | err.withSafeMetadata({ 1139 | requestId: 'req-12345' 1140 | }) 1141 | ``` 1142 | 1143 | ### Internal metadata 1144 | 1145 | Method: `BaseError#withMetadata(data = {})` 1146 | 1147 | Internal metadata would be any kind of data that you would *not be* ok with exposing to a client, 1148 | but would be useful for internal development / logging purposes. 1149 | 1150 | ```typescript 1151 | err.withMetadata({ 1152 | email: 'test@test.com' 1153 | }) 1154 | // can be chained to append more data 1155 | .withMetadata({ 1156 | userId: 'user-abcd' 1157 | }) 1158 | ``` 1159 | 1160 | ## Serializing errors 1161 | 1162 | ### Safe serialization 1163 | 1164 | Method: `BaseError#toJSONSafe(fieldsToOmit = [])` 1165 | 1166 | Generates output that would be safe for client consumption. 1167 | 1168 | - Omits `name` 1169 | - Omits `message` 1170 | - Omits `causedBy` 1171 | - Omits `type` 1172 | - Omits `logLevel` 1173 | - Omits the stack trace 1174 | - Omits any data defined via `BaseError#withMetadata()` 1175 | 1176 | ```typescript 1177 | err.withSafeMetadata({ 1178 | requestId: 'req-12345' 1179 | }) 1180 | // you can remove additional fields by specifying property names in an array 1181 | //.toJSONSafe(['code']) removes the code field from output 1182 | .toJSONSafe() 1183 | ``` 1184 | 1185 | Produces: 1186 | 1187 | ``` 1188 | { 1189 | code: 'ERR_INT_500', 1190 | subCode: 'DB_0001', 1191 | statusCode: 500, 1192 | meta: { requestId: 'req-12345' } 1193 | } 1194 | ``` 1195 | 1196 | ### Internal serialization 1197 | 1198 | Method: `BaseError#toJSON(fieldsToOmit = [])` 1199 | 1200 | Generates output that would be suitable for internal use. 1201 | 1202 | - Includes `name` 1203 | - Includes `type` 1204 | - Includes `message` 1205 | - Includes `causedBy` 1206 | - Includes the stack trace 1207 | - All data from `BaseError#withMetadata()` and `BaseError#withSafeMetadata()` is included 1208 | 1209 | ```typescript 1210 | err.withSafeMetadata({ 1211 | reqId: 'req-12345', 1212 | }).withMetadata({ 1213 | email: 'test@test.com' 1214 | }) 1215 | // you can remove additional fields by specifying property names in an array 1216 | //.toJSON(['code', 'statusCode']) removes the code and statusCode field from output 1217 | .toJSON() 1218 | ``` 1219 | 1220 | Produces: 1221 | 1222 | ``` 1223 | { 1224 | name: 'InternalServerError', 1225 | code: 'ERR_INT_500', 1226 | message: 'There was a database failure, SQL err code %s', 1227 | type: 'DATABASE_FAILURE', 1228 | subCode: 'DB_0001', 1229 | statusCode: 500, 1230 | meta: { errorId: 'err-12345', requestId: 'req-12345' }, 1231 | stack: 'InternalServerError: There was a database failure, SQL err code %s\n' + 1232 | ' at ErrorRegistry.newError (new-error/src/ErrorRegistry.ts:128:12)\n' + 1233 | ' at Object. (new-error/src/test.ts:55:25)\n' + 1234 | ' at Module._compile (internal/modules/cjs/loader.js:1158:30)\n' + 1235 | ' at Module._compile (new-error/node_modules/source-map-support/source-map-support.js:541:25)\n' + 1236 | ' at Module.m._compile (/private/var/folders/mx/b54hc2lj3fbfsndkv4xmz8d80000gn/T/ts-node-dev-hook-17091160954051898.js:57:25)\n' + 1237 | ' at Module._extensions..js (internal/modules/cjs/loader.js:1178:10)\n' + 1238 | ' at require.extensions. (/private/var/folders/mx/b54hc2lj3fbfsndkv4xmz8d80000gn/T/ts-node-dev-hook-17091160954051898.js:59:14)\n' + 1239 | ' at Object.nodeDevHook [as .ts] (new-error/node_modules/ts-node-dev/lib/hook.js:61:7)\n' + 1240 | ' at Module.load (internal/modules/cjs/loader.js:1002:32)\n' + 1241 | ' at Function.Module._load (internal/modules/cjs/loader.js:901:14)' 1242 | } 1243 | ``` 1244 | 1245 | ### Post-processing handlers 1246 | 1247 | The `BaseError` config `onPreToJSONData` / `onPreToJSONSafeData` options allow post-processing of the data. This is useful if you want to decorate your data for all new 1248 | errors created. 1249 | 1250 | ```ts 1251 | const errRegistry = new ErrorRegistry(errors, errorSubCodes, { 1252 | baseErrorConfig: { 1253 | // called when toJSON is called 1254 | onPreToJSONData: (data) => { 1255 | // we want all new errors to contain a date field 1256 | data.date = new Date().tostring() 1257 | 1258 | // add some additional metadata 1259 | // data.meta might be empty if omitEmptyMetadata is enabled 1260 | if (data.meta) { 1261 | data.meta.moreData = 'test' 1262 | } 1263 | 1264 | return data 1265 | } 1266 | } 1267 | }) 1268 | 1269 | const err = errRegistry.newError('INTERNAL_SERVER_ERROR', 'DATABASE_FAILURE') 1270 | .withErrorId('err-1234') 1271 | .formatMessage('SQL_1234') 1272 | 1273 | // should produce the standard error structure, but with the new fields added 1274 | console.log(err.toJSON()) 1275 | ``` 1276 | 1277 | # Deserialization 1278 | 1279 | ## Issues with deserialization 1280 | 1281 | ### Deserialization is not perfect 1282 | 1283 | - If the serialized output lacks the `name` property (not present when using `toJSONSafe()`), then only a `BaseError` instance can be returned. 1284 | - The metadata is squashed in the serialized output that information is required to separate them. 1285 | - It is difficult to determine the original type / structure of the `causedBy` data. As a result, it will be copied as-is. 1286 | 1287 | ### Potential security issues with deserialization 1288 | 1289 | - You need to be able to trust the data you're deserializing as the serialized data can be modified in various ways by 1290 | an untrusted party. 1291 | - The deserialization implementation does not perform `JSON.parse()` as `JSON.parse()` in its raw form is susceptible to 1292 | [prototype pollution](https://medium.com/intrinsic/javascript-prototype-poisoning-vulnerabilities-in-the-wild-7bc15347c96) 1293 | if the parse function does not have a proper sanitization function. It is up to the developer to properly 1294 | trust / sanitize / parse the data. 1295 | 1296 | ## `ErrorRegistry#fromJSON()` method 1297 | 1298 | This method will attempt to deserialize into a registered error type via the `name` property. If it is unable to, a `BaseError` instance is 1299 | returned instead. 1300 | 1301 | `ErrorRegistry#fromJSON(data: object, [options]: DeserializeOpts): IBaseError` 1302 | 1303 | - `data`: Data that is the output of `BaseError#toJSON()`. The data must be an object, not a string. 1304 | - `options`: Optional deserialization options. 1305 | 1306 | ```typescript 1307 | interface DeserializeOpts { 1308 | /** 1309 | * Fields from meta to pluck as a safe metadata field 1310 | */ 1311 | safeMetadataFields?: { 1312 | // the value must be set to true. 1313 | [key: string]: true 1314 | } 1315 | } 1316 | ``` 1317 | 1318 | Returns a `BaseError` instance or an instance of a registered error type. 1319 | 1320 | ```typescript 1321 | import { ErrorRegistry } from 'new-error' 1322 | 1323 | const errors = { 1324 | INTERNAL_SERVER_ERROR: { 1325 | className: 'InternalServerError', 1326 | code: 'ERR_INT_500', 1327 | statusCode: 500, 1328 | logLevel: 'error' 1329 | } 1330 | } 1331 | 1332 | const errorSubCodes = { 1333 | DATABASE_FAILURE: { 1334 | message: 'There was a database failure, SQL err code %s', 1335 | subCode: 'DB_0001', 1336 | statusCode: 500, 1337 | logLevel: 'error' 1338 | } 1339 | } 1340 | 1341 | const errRegistry = new ErrorRegistry(errors, errorSubCodes) 1342 | 1343 | const data = { 1344 | 'errId': 'err-123', 1345 | 'code': 'ERR_INT_500', 1346 | 'subCode': 'DB_0001', 1347 | 'message': 'test message', 1348 | 'meta': { 'safeData': 'test454', 'test': 'test123' }, 1349 | // maps to className in the high level error def 1350 | 'name': 'InternalServerError', 1351 | 'statusCode': 500, 1352 | 'causedBy': 'test', 1353 | 'stack': 'abcd' 1354 | } 1355 | 1356 | // err should be an instance of InternalServerError 1357 | const err = errRegistry.fromJSON(data, { 1358 | safeMetadataFields: { 1359 | safeData: true 1360 | } 1361 | }) 1362 | ``` 1363 | 1364 | ## `static BaseError#fromJSON()` method 1365 | 1366 | If you are not using the registry, you can deserialize using this method. This also applies to any class that extends 1367 | `BaseError`. 1368 | 1369 | `static BaseError#fromJSON(data: object, [options]: DeserializeOpts): IBaseError` 1370 | 1371 | - `data`: Data that is the output of `BaseError#toJSON()`. The data must be an object, not a string. 1372 | - `options`: Optional deserialization options. 1373 | 1374 | Returns a `BaseError` instance or an instance of the class that extends it. 1375 | 1376 | ```typescript 1377 | import { BaseError } from 'new-error' 1378 | 1379 | // assume we have serialized error data 1380 | const data = { 1381 | code: 'ERR_INT_500', 1382 | subCode: 'DB_0001', 1383 | statusCode: 500, 1384 | errId: 'err-1234', 1385 | meta: { requestId: 'req-12345', safeData: '123' } 1386 | } 1387 | 1388 | // deserialize 1389 | // specify meta field assignment - fields that are not assigned will be assumed as withMetadata() type data 1390 | const err = BaseError.fromJSON(data, { 1391 | // (optional) Fields to pluck from 'meta' to be sent to BaseError#safeMetadataFields() 1392 | // value must be set to 'true' 1393 | safeMetadataFields: { 1394 | safeData: true 1395 | } 1396 | }) 1397 | ``` 1398 | 1399 | ## Stand-alone instance-based deserialization 1400 | 1401 | If the `name` property is present in the serialized data if it was serialized with `toJson()`, you can use a switch 1402 | to map to an instance: 1403 | 1404 | ```typescript 1405 | const data = { 1406 | // be sure that you trust the source of the deserialized data! 1407 | // anyone can modify the 'name' property to whatever 1408 | name: 'InternalServerError', 1409 | code: 'ERR_INT_500', 1410 | subCode: 'DB_0001', 1411 | statusCode: 500, 1412 | errId: 'err-1234', 1413 | meta: { requestId: 'req-12345', safeData: '123' } 1414 | } 1415 | 1416 | let err = null 1417 | 1418 | switch (data.name) { 1419 | case 'InternalServerError': 1420 | // assume InternalServerError extends BaseError 1421 | return InternalServerError.fromJSON(data) 1422 | default: 1423 | return BaseError.fromJSON(data) 1424 | } 1425 | ``` 1426 | 1427 | # Looking for production-grade env variable / configuration management? 1428 | 1429 | Check out https://github.com/theogravity/configurity 1430 | --------------------------------------------------------------------------------