├── .gitattributes ├── gulpfile.js ├── prettier.config.js ├── ava.config.js ├── tsconfig.json ├── examples └── plugin │ ├── tsconfig.json │ ├── main.test.js │ ├── main.test.ts │ ├── README.md │ ├── main.js │ ├── main.d.ts │ ├── main.test-d.ts │ └── main.ts ├── src ├── plugins │ ├── properties │ │ ├── assign.d.ts │ │ ├── main.d.ts │ │ ├── main.test-d.ts │ │ ├── assign.js │ │ ├── main.js │ │ ├── assign.test-d.ts │ │ └── main.test.js │ ├── core │ │ ├── main.js │ │ ├── props │ │ │ ├── main.d.ts │ │ │ ├── main.js │ │ │ ├── main.test.js │ │ │ └── main.test-d.ts │ │ └── all.test-d.ts │ ├── shape │ │ ├── main.test-d.ts │ │ ├── name.test.js │ │ ├── name.js │ │ ├── duplicate.test.js │ │ ├── methods.test.js │ │ ├── duplicate.js │ │ ├── main.js │ │ ├── methods.js │ │ └── main.test.js │ ├── static │ │ ├── main.d.ts │ │ ├── main.test.js │ │ ├── call.js │ │ ├── main.test-d.ts │ │ ├── call.d.ts │ │ ├── main.js │ │ └── call.test-d.ts │ ├── instance │ │ ├── main.d.ts │ │ ├── mixed.js │ │ ├── main.test-d.ts │ │ ├── call.js │ │ ├── main.js │ │ ├── mixed.d.ts │ │ ├── call.d.ts │ │ ├── main.test.js │ │ ├── call.test-d.ts │ │ └── mixed.test-d.ts │ └── info │ │ ├── main.js │ │ ├── error.js │ │ ├── main.test-d.ts │ │ ├── main.test.js │ │ └── error.test.js ├── utils │ ├── intersect.d.ts │ ├── slice.d.ts │ ├── descriptors.js │ ├── omit.d.ts │ └── subclass.js ├── options │ ├── get.d.ts │ ├── method.d.ts │ ├── instance.test-d.ts │ ├── get.test-d.ts │ ├── get.js │ ├── clone.test.js │ ├── get.test.js │ ├── clone.js │ ├── class.js │ ├── merge.js │ ├── plugins.d.ts │ ├── class.d.ts │ ├── instance.js │ ├── class.test.js │ ├── plugins.js │ ├── method.js │ ├── method.test.js │ ├── instance.d.ts │ ├── class.test-d.ts │ ├── instance.test.js │ └── plugins.test-d.ts ├── helpers │ ├── unknown.test.js │ ├── main.test.js │ ├── info.test.js │ └── plugin.test.js ├── subclass │ ├── validate.test.js │ ├── map.js │ ├── validate.js │ ├── custom.js │ ├── custom.d.ts │ ├── create.test.js │ ├── check.js │ ├── custom.test.js │ ├── check.test.js │ ├── create.js │ ├── normalize.d.ts │ ├── normalize.js │ ├── normalize.test-d.ts │ └── create.d.ts ├── merge │ ├── aggregate.js │ ├── aggregate.test.js │ ├── cause.d.ts │ ├── cause.js │ ├── prefix.js │ ├── aggregate.test-d.ts │ ├── aggregate.d.ts │ └── cause.test.js ├── main.js ├── main.test.js └── main.d.ts ├── .gitignore ├── .github ├── workflows │ └── workflow.yml ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── feature_request.yml │ ├── bug_types.yml │ └── bug_report.yml ├── .editorconfig ├── eslint.config.js ├── LICENSE ├── CONTRIBUTING.md ├── package.json └── .all-contributorsrc /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | export * from '@ehmicky/dev-tasks' 2 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | export { default } from '@ehmicky/prettier-config' 2 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | export { default } from '@ehmicky/dev-tasks/ava.config.js' 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@ehmicky/dev-tasks/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /examples/plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@ehmicky/dev-tasks/tsconfig.json", 3 | "files": ["main.d.ts", "main.test-d.ts", "main.test.ts", "main.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/plugins/properties/assign.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unbound added properties of a plugin 3 | */ 4 | export interface AddedProperties { 5 | [PropName: string]: unknown 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | npm-debug.log 4 | node_modules 5 | /core 6 | .eslintcache 7 | .lycheecache 8 | .npmrc 9 | .yarn-error.log 10 | !.github/ 11 | /coverage 12 | /build 13 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push, pull_request] 3 | jobs: 4 | combinations: 5 | uses: ehmicky/dev-tasks/.github/workflows/build.yml@main 6 | secrets: inherit 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | max_line_length = 80 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /src/plugins/core/main.js: -------------------------------------------------------------------------------- 1 | import PROPS_PLUGIN from './props/main.js' 2 | 3 | // Plugins included by default. 4 | // Order is significant, since last plugins `properties()` have priority. 5 | export const CORE_PLUGINS = [PROPS_PLUGIN] 6 | -------------------------------------------------------------------------------- /src/utils/intersect.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Turn `T | T2 | ...` into `T & T2 & ...` 3 | */ 4 | export type UnionToIntersection = ( 5 | T extends unknown ? (arg: T) => unknown : never 6 | ) extends (arg: infer U) => unknown 7 | ? U 8 | : never 9 | -------------------------------------------------------------------------------- /src/utils/slice.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Omit the first item of a tuple 3 | */ 4 | export type SliceFirst = 5 | Tuple extends readonly [unknown, ...infer Rest extends readonly unknown[]] 6 | ? Rest 7 | : readonly never[] 8 | -------------------------------------------------------------------------------- /src/options/get.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * `plugin.getOptions()` method 3 | */ 4 | export type GetOptions = (input: never, full: boolean) => unknown 5 | 6 | /** 7 | * `plugin.isOptions()` method 8 | */ 9 | export type IsOptions = (input: unknown) => boolean 10 | -------------------------------------------------------------------------------- /src/utils/descriptors.js: -------------------------------------------------------------------------------- 1 | // Most error core properties are not enumerable 2 | export const setNonEnumProp = (object, propName, value) => { 3 | // eslint-disable-next-line fp/no-mutating-methods 4 | Object.defineProperty(object, propName, { 5 | value, 6 | enumerable: false, 7 | writable: true, 8 | configurable: true, 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /src/helpers/unknown.test.js: -------------------------------------------------------------------------------- 1 | import { runInNewContext } from 'node:vm' 2 | 3 | export const getUnknownErrors = () => [ 4 | ...getUnknownErrorInstances(), 5 | () => 'message', 6 | () => {}, 7 | ] 8 | 9 | export const getUnknownErrorInstances = () => 10 | [TypeError, Error, runInNewContext('Error')].map( 11 | (ErrorClass) => () => new ErrorClass('message'), 12 | ) 13 | -------------------------------------------------------------------------------- /src/plugins/shape/main.test-d.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'modern-errors' 2 | import { expectAssignable, expectNotAssignable } from 'tsd' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 5 | const name = 'test' as const 6 | expectAssignable({ name }) 7 | expectNotAssignable({}) 8 | expectNotAssignable({ name, unknown: true }) 9 | -------------------------------------------------------------------------------- /examples/plugin/main.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | 3 | import ModernError from 'modern-errors' 4 | import modernErrorsExample from 'modern-errors-example' 5 | 6 | const BaseError = ModernError.subclass('BaseError', { 7 | plugins: [modernErrorsExample], 8 | }) 9 | const error = new BaseError('') 10 | 11 | assert.equal(BaseError.exampleMethod(error), 'expectedValue') 12 | -------------------------------------------------------------------------------- /examples/plugin/main.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | 3 | import ModernError from 'modern-errors' 4 | import modernErrorsExample from 'modern-errors-example' 5 | 6 | const BaseError = ModernError.subclass('BaseError', { 7 | plugins: [modernErrorsExample], 8 | }) 9 | const error = new BaseError('') 10 | 11 | assert.equal(BaseError.exampleMethod(error), 'expectedValue') 12 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslintConfig from '@ehmicky/eslint-config' 2 | 3 | export default [ 4 | ...eslintConfig, 5 | { 6 | rules: { 7 | 'fp/no-class': 0, 8 | }, 9 | }, 10 | { 11 | files: ['examples/plugin/*.ts'], 12 | settings: { 13 | 'import/resolver': { 14 | typescript: { project: 'examples/plugin/tsconfig.json' }, 15 | }, 16 | }, 17 | }, 18 | ] 19 | -------------------------------------------------------------------------------- /src/plugins/core/props/main.d.ts: -------------------------------------------------------------------------------- 1 | import type { SimpleSetProps } from '../../../utils/omit.js' 2 | 3 | /** 4 | * Error properties 5 | */ 6 | export type ErrorProps = object 7 | 8 | /** 9 | * Merge error `props` from the class options and instance options 10 | */ 11 | export type MergeErrorProps< 12 | PropsOne extends ErrorProps, 13 | PropsTwo extends ErrorProps, 14 | > = SimpleSetProps 15 | -------------------------------------------------------------------------------- /src/plugins/static/main.d.ts: -------------------------------------------------------------------------------- 1 | import type { InfoParameter } from '../info/main.js' 2 | 3 | /** 4 | * Unbound static method of a plugin 5 | */ 6 | export type StaticMethod = ( 7 | info: InfoParameter['staticMethods'], 8 | ...args: readonly never[] 9 | ) => unknown 10 | 11 | /** 12 | * Unbound static methods of a plugin 13 | */ 14 | export interface StaticMethods { 15 | readonly [MethodName: string]: StaticMethod 16 | } 17 | -------------------------------------------------------------------------------- /src/plugins/instance/main.d.ts: -------------------------------------------------------------------------------- 1 | import type { InfoParameter } from '../info/main.js' 2 | 3 | /** 4 | * Unbound instance method of a plugin 5 | */ 6 | export type InstanceMethod = ( 7 | info: InfoParameter['instanceMethods'], 8 | ...args: readonly never[] 9 | ) => unknown 10 | 11 | /** 12 | * Unbound instance methods of a plugin 13 | */ 14 | export interface InstanceMethods { 15 | readonly [MethodName: string]: InstanceMethod 16 | } 17 | -------------------------------------------------------------------------------- /examples/plugin/README.md: -------------------------------------------------------------------------------- 1 | # Plugin example 2 | 3 | This directory contains examples of a `modern-errors` plugin: 4 | 5 | - Main file: [JavaScript](main.js), [TypeScript ambient file](main.d.ts), 6 | [TypeScript without an ambient file](main.ts) 7 | - Integration tests: [JavaScript](main.test.js), [TypeScript](main.test.ts) 8 | - [Type tests](main.test-d.ts) 9 | 10 | [Existing plugins](../../README.md#plugins) can also be used for inspiration. 11 | -------------------------------------------------------------------------------- /src/options/method.d.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from '../plugins/shape/main.js' 2 | 3 | import type { ExternalPluginOptions } from './plugins.js' 4 | 5 | /** 6 | * Options passed to plugin methods: 7 | * `ErrorClass.{staticMethod}(..., options)`, 8 | * `ErrorClass.{instanceMethod}(error, ..., options)` or 9 | * `error.{instanceMethod}(..., options)` 10 | */ 11 | export type MethodOptions = 12 | ExternalPluginOptions 13 | -------------------------------------------------------------------------------- /src/helpers/main.test.js: -------------------------------------------------------------------------------- 1 | import ModernError from 'modern-errors' 2 | 3 | export { ModernError } 4 | 5 | export const getClasses = (opts) => { 6 | const BaseError = ModernError.subclass('BaseError', opts) 7 | const ChildError = BaseError.subclass('ChildError') 8 | const ErrorSubclassesArg = [BaseError, ChildError] 9 | const ErrorClassesArg = [ModernError, ...ErrorSubclassesArg] 10 | return { ErrorClasses: ErrorClassesArg, ErrorSubclasses: ErrorSubclassesArg } 11 | } 12 | 13 | export const { ErrorClasses, ErrorSubclasses } = getClasses() 14 | -------------------------------------------------------------------------------- /src/subclass/validate.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses } from '../helpers/main.test.js' 5 | 6 | each(ErrorClasses, ({ title }, ErrorClass) => { 7 | test(`Cannot extend without subclass() | ${title}`, (t) => { 8 | class TestError extends ErrorClass {} 9 | t.throws(() => new TestError('test')) 10 | }) 11 | 12 | test(`Can extend with subclass() | ${title}`, (t) => { 13 | const TestError = ErrorClass.subclass('TestError') 14 | t.is(new TestError('test').constructor, TestError) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/subclass/map.js: -------------------------------------------------------------------------------- 1 | // We use a global `WeakMap` to store class-specific information (such as 2 | // options) instead of storing it as a symbol property on each error class to 3 | // ensure: 4 | // - This is not exposed to users or plugin authors 5 | // - This does not change how the error class is printed 6 | // We use a `WeakMap` instead of an object since the key should be the error 7 | // class, not its `name`, because classes might have duplicate names. 8 | export const classesData = new WeakMap() 9 | 10 | // The same but for error instances 11 | export const instancesData = new WeakMap() 12 | -------------------------------------------------------------------------------- /src/plugins/core/props/main.js: -------------------------------------------------------------------------------- 1 | import isPlainObj from 'is-plain-obj' 2 | 3 | // Error properties can be set using the `props` option 4 | const getOptions = (options = {}) => { 5 | if (!isPlainObj(options)) { 6 | throw new TypeError(`It must be a plain object: ${options}`) 7 | } 8 | 9 | // eslint-disable-next-line no-unused-vars 10 | const { message, ...optionsA } = options 11 | return optionsA 12 | } 13 | 14 | // Set `props` option as error properties 15 | const properties = ({ options }) => options 16 | 17 | // eslint-disable-next-line import/no-default-export 18 | export default { 19 | name: 'props', 20 | getOptions, 21 | properties, 22 | } 23 | -------------------------------------------------------------------------------- /src/plugins/static/main.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorSubclasses } from '../../helpers/plugin.test.js' 5 | 6 | each(ErrorSubclasses, ({ title }, ErrorClass) => { 7 | test(`plugin.staticMethods are set on ErrorClass | ${title}`, (t) => { 8 | t.is(typeof ErrorClass.getProp, 'function') 9 | }) 10 | 11 | test(`plugin.staticMethods context is bound | ${title}`, (t) => { 12 | const { getProp } = ErrorClass 13 | t.deepEqual(getProp(0).args, [0]) 14 | }) 15 | 16 | test(`Plugin static methods are not enumerable | ${title}`, (t) => { 17 | t.deepEqual(Object.keys(ErrorClass), []) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/plugins/shape/name.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses } from '../../helpers/main.test.js' 5 | import { TEST_PLUGIN } from '../../helpers/plugin.test.js' 6 | 7 | each( 8 | ErrorClasses, 9 | [ 10 | undefined, 11 | true, 12 | '', 13 | 'testProp', 14 | 'test-prop', 15 | 'test_prop', 16 | '0test', 17 | 'cause', 18 | 'errors', 19 | 'custom', 20 | 'wrap', 21 | 'constructorArgs', 22 | ], 23 | ({ title }, ErrorClass, name) => { 24 | test(`Should validate plugin.name | ${title}`, (t) => { 25 | t.throws( 26 | ErrorClass.subclass.bind(undefined, 'TestError', { 27 | plugins: [{ ...TEST_PLUGIN, name }], 28 | }), 29 | ) 30 | }) 31 | }, 32 | ) 33 | -------------------------------------------------------------------------------- /src/plugins/instance/mixed.js: -------------------------------------------------------------------------------- 1 | import { callMethod } from './call.js' 2 | 3 | // Called on `ErrorClass[methodName](error, ...args)` 4 | // All instance methods are also available as static methods 5 | // - This reduces the potential for the common mistake of calling 6 | // `error[methodName]` without first normalizing `error` 7 | // - This is the preferred way, which should be solely documented 8 | // - `error[methodName]` still has a few use cases though: 9 | // - Method chaining 10 | // - Known methods, e.g. `error.toJSON()` 11 | export const callMixedMethod = ( 12 | { methodFunc, plugin, plugins, ErrorClass }, 13 | error, 14 | ...args 15 | ) => { 16 | const errorA = ErrorClass.normalize(error) 17 | return callMethod({ methodFunc, plugin, plugins, error: errorA, args }) 18 | } 19 | -------------------------------------------------------------------------------- /src/options/instance.test-d.ts: -------------------------------------------------------------------------------- 1 | import type { ClassOptions, InstanceOptions } from 'modern-errors' 2 | import { expectAssignable, expectNotAssignable } from 'tsd' 3 | 4 | expectAssignable({}) 5 | 6 | expectAssignable({ cause: new Error('') }) 7 | expectAssignable({ cause: '' }) 8 | expectAssignable({ cause: undefined }) 9 | expectNotAssignable({ cause: '' }) 10 | 11 | expectAssignable({ errors: [new Error('')] as const }) 12 | expectAssignable({ errors: [''] }) 13 | expectAssignable({ errors: [undefined] }) 14 | expectAssignable({ errors: undefined }) 15 | expectNotAssignable({ errors: [''] }) 16 | expectNotAssignable({ errors: '' }) 17 | -------------------------------------------------------------------------------- /src/merge/aggregate.js: -------------------------------------------------------------------------------- 1 | import { setNonEnumProp } from '../utils/descriptors.js' 2 | 3 | // Array of `errors` can be set using an option. 4 | // This is like `AggregateError` except: 5 | // - This is available in any class, removing the need to create separate 6 | // classes for it 7 | // - Any class can opt-in to it or not 8 | // - This uses a named parameter instead of a positional one: 9 | // - This is more monomorphic 10 | // - This parallels the `cause` option 11 | // Child `errors` are always kept, only appended to. 12 | export const setAggregateErrors = (error, errors) => { 13 | if (errors === undefined) { 14 | return 15 | } 16 | 17 | if (!Array.isArray(errors)) { 18 | throw new TypeError(`"errors" option must be an array: ${errors}`) 19 | } 20 | 21 | setNonEnumProp(error, 'errors', errors) 22 | } 23 | -------------------------------------------------------------------------------- /src/plugins/static/call.js: -------------------------------------------------------------------------------- 1 | import { getMethodOpts } from '../../options/method.js' 2 | import { finalizePluginsOpts } from '../../options/plugins.js' 3 | import { classesData } from '../../subclass/map.js' 4 | import { getPluginInfo } from '../info/main.js' 5 | 6 | // Called on `ErrorClass[methodName](...args)` 7 | export const callStaticMethod = ( 8 | { methodFunc, plugin, plugins, ErrorClass }, 9 | ...args 10 | ) => { 11 | const { classOpts } = classesData.get(ErrorClass) 12 | const { args: argsA, methodOpts } = getMethodOpts(args, plugin) 13 | const options = finalizePluginsOpts({ 14 | pluginsOpts: classOpts, 15 | methodOpts, 16 | plugins, 17 | plugin, 18 | }) 19 | const info = getPluginInfo({ 20 | options, 21 | ErrorClass, 22 | methodOpts, 23 | plugins, 24 | plugin, 25 | }) 26 | return methodFunc(info, ...argsA) 27 | } 28 | -------------------------------------------------------------------------------- /src/options/get.test-d.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'modern-errors' 2 | import { expectAssignable, expectNotAssignable } from 'tsd' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 5 | const name = 'test' as const 6 | 7 | expectAssignable({ 8 | name, 9 | getOptions: (input: true, full: boolean) => input, 10 | }) 11 | expectAssignable({ name, getOptions: () => true }) 12 | expectNotAssignable({ name, getOptions: true }) 13 | expectNotAssignable({ 14 | name, 15 | getOptions: (input: true, full: string) => input, 16 | }) 17 | 18 | expectAssignable({ name, isOptions: (input: unknown) => true }) 19 | expectNotAssignable({ name, isOptions: true }) 20 | expectNotAssignable({ name, isOptions: (input: true) => true }) 21 | expectNotAssignable({ name, isOptions: (input: unknown) => 0 }) 22 | -------------------------------------------------------------------------------- /src/plugins/instance/main.test-d.ts: -------------------------------------------------------------------------------- 1 | import type { Info, Plugin } from 'modern-errors' 2 | import { expectAssignable, expectNotAssignable } from 'tsd' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 5 | const name = 'test' as const 6 | 7 | expectAssignable({ 8 | name, 9 | instanceMethods: { 10 | instanceMethod: (info: Info['instanceMethods'], one: '', two: '') => '', 11 | }, 12 | }) 13 | expectAssignable({ name, instanceMethods: {} }) 14 | expectNotAssignable({ name, instanceMethods: true }) 15 | expectNotAssignable({ name, instanceMethods: { instanceMethod: true } }) 16 | expectNotAssignable({ 17 | name, 18 | instanceMethods: { instanceMethod: (info: true) => '' }, 19 | }) 20 | expectNotAssignable({ 21 | name, 22 | instanceMethods: { instanceMethod: (info: { one: '' }) => '' }, 23 | }) 24 | -------------------------------------------------------------------------------- /src/plugins/instance/call.js: -------------------------------------------------------------------------------- 1 | import { getMethodOpts } from '../../options/method.js' 2 | import { getErrorPluginInfo } from '../info/main.js' 3 | 4 | // Called on `error[methodName](...args)` 5 | export const callInstanceMethod = ({ 6 | error, 7 | methodFunc, 8 | methodName, 9 | plugin, 10 | plugins, 11 | ErrorClass, 12 | args, 13 | }) => { 14 | if (!(error instanceof ErrorClass)) { 15 | throw new TypeError( 16 | `Missing "this" context: "${methodName}()" must be called using "error.${methodName}()"`, 17 | ) 18 | } 19 | 20 | return callMethod({ methodFunc, plugin, plugins, error, args }) 21 | } 22 | 23 | export const callMethod = ({ methodFunc, plugin, plugins, error, args }) => { 24 | const { args: argsA, methodOpts } = getMethodOpts(args, plugin) 25 | const info = getErrorPluginInfo({ error, methodOpts, plugins, plugin }) 26 | return methodFunc(info, ...argsA) 27 | } 28 | -------------------------------------------------------------------------------- /examples/plugin/main.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Name used to configure the plugin 3 | name: 'example', 4 | 5 | // // Set error properties 6 | // properties(info) { 7 | // return {} 8 | // }, 9 | 10 | // // Add error instance methods like 11 | // // `ErrorClass.exampleMethod(error, ...args)` 12 | // instanceMethods: { 13 | // exampleMethod(info, ...args) { 14 | // // ... 15 | // }, 16 | // }, 17 | 18 | // // Add `ErrorClass` static methods like `ErrorClass.staticMethod(...args)` 19 | // staticMethods: { 20 | // staticMethod(info, ...args) { 21 | // // ... 22 | // }, 23 | // }, 24 | 25 | // // Validate and normalize options 26 | // getOptions(options, full) { 27 | // return options 28 | // }, 29 | 30 | // // Determine if a value is plugin's options 31 | // isOptions(options) { 32 | // return typeof options === 'boolean' 33 | // }, 34 | } 35 | -------------------------------------------------------------------------------- /src/plugins/properties/main.d.ts: -------------------------------------------------------------------------------- 1 | import type { UnionToIntersection } from '../../utils/intersect.js' 2 | import type { InfoParameter } from '../info/main.js' 3 | import type { Plugin, Plugins } from '../shape/main.js' 4 | 5 | import type { AddedProperties } from './assign.js' 6 | 7 | /** 8 | * Bound added properties of a plugin, always defined 9 | */ 10 | type GetProperties = (info: InfoParameter['properties']) => AddedProperties 11 | 12 | /** 13 | * Bound added properties of a plugin, if defined 14 | */ 15 | type PluginProperties = PluginArg extends Plugin 16 | ? PluginArg extends { properties: GetProperties } 17 | ? ReturnType 18 | : object 19 | : object 20 | 21 | /** 22 | * Bound added properties of all plugins 23 | */ 24 | export type PluginsProperties = UnionToIntersection< 25 | PluginProperties 26 | > & {} 27 | -------------------------------------------------------------------------------- /src/options/get.js: -------------------------------------------------------------------------------- 1 | import { mergeSpecificCause } from '../merge/cause.js' 2 | 3 | // `options` is `undefined` unless `plugin.getOptions()` is defined 4 | // - This encourages using `plugin.getOptions()` 5 | export const normalizeGetOptions = ({ 6 | plugin, 7 | plugin: { 8 | fullName, 9 | getOptions = defaultGetOptions.bind(undefined, fullName), 10 | }, 11 | }) => ({ ...plugin, getOptions }) 12 | 13 | const defaultGetOptions = (fullName, options) => { 14 | if (options !== undefined) { 15 | throw new Error( 16 | `The plugin "${fullName}" does not have any options: ${options}`, 17 | ) 18 | } 19 | } 20 | 21 | // Call `plugin.getOptions()` 22 | export const getPluginOpts = ({ 23 | pluginsOpts, 24 | plugin: { name, getOptions }, 25 | full, 26 | }) => { 27 | try { 28 | return getOptions(pluginsOpts[name], full) 29 | } catch (cause) { 30 | throw mergeSpecificCause(new Error(`Invalid "${name}" options:`), cause) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/plugins/properties/main.test-d.ts: -------------------------------------------------------------------------------- 1 | import type { Info, Plugin } from 'modern-errors' 2 | import { expectAssignable, expectNotAssignable } from 'tsd' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 5 | const name = 'test' as const 6 | const emptyPlugin = { name } 7 | const fullPlugin = { 8 | ...emptyPlugin, 9 | properties: (info: Info['properties']) => ({ property: true }) as const, 10 | } 11 | 12 | expectAssignable(fullPlugin) 13 | expectNotAssignable({ name, properties: true }) 14 | expectNotAssignable({ name, properties: (info: true) => ({}) }) 15 | expectNotAssignable({ name, properties: (info: { one: '' }) => ({}) }) 16 | expectNotAssignable({ 17 | name, 18 | properties: (info: Info['properties'], arg: true) => ({}), 19 | }) 20 | expectNotAssignable({ 21 | name, 22 | properties: (info: Info['properties']) => true, 23 | }) 24 | expectNotAssignable({ 25 | name, 26 | properties: (info: Info['properties']) => [], 27 | }) 28 | -------------------------------------------------------------------------------- /src/utils/omit.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Like `Omit` except it reduces empty `{}` for simpler 3 | * debugging. 4 | */ 5 | export type OmitKeys< 6 | Source, 7 | OmittedKeys extends PropertyKey, 8 | > = keyof Source extends OmittedKeys ? object : Omit 9 | 10 | /** 11 | * Like `SetProps` except it reduces empty `{}` for 12 | * simpler debugging. 13 | */ 14 | export type SimpleSetProps< 15 | LowObject extends object, 16 | HighObject extends object, 17 | > = keyof LowObject extends keyof HighObject 18 | ? HighObject 19 | : keyof LowObject & keyof HighObject extends never 20 | ? LowObject & HighObject 21 | : SetProps 22 | 23 | /** 24 | * Like `LowObject & HighObject` except that if both keys are defined, 25 | * `HighObject` overrides `LowObject` instead of intersecting to it 26 | */ 27 | export type SetProps< 28 | LowObject extends object, 29 | HighObject extends object, 30 | > = Omit & HighObject 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2025 ehmicky 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 🎉 Thanks for sending this pull request! 🎉 2 | 3 | Please make sure the title is clear and descriptive. 4 | 5 | If you are fixing a typo or documentation, please skip these instructions. 6 | 7 | Otherwise please fill in the sections below. 8 | 9 | **Which problem is this pull request solving?** 10 | 11 | Example: I'm always frustrated when [...] 12 | 13 | **List other issues or pull requests related to this problem** 14 | 15 | Example: This fixes #5012 16 | 17 | **Describe the solution you've chosen** 18 | 19 | Example: I've fixed this by [...] 20 | 21 | **Describe alternatives you've considered** 22 | 23 | Example: Another solution would be [...] 24 | 25 | **Checklist** 26 | 27 | Please add a `x` inside each checkbox: 28 | 29 | - [ ] I have read the [contribution guidelines](../blob/main/CONTRIBUTING.md). 30 | - [ ] I have added tests (we are enforcing 100% test coverage). 31 | - [ ] I have added documentation in the `README.md`, the `docs` directory (if 32 | any) 33 | - [ ] The status checks are successful (continuous integration). Those can be 34 | seen below. 35 | -------------------------------------------------------------------------------- /src/plugins/static/main.test-d.ts: -------------------------------------------------------------------------------- 1 | import type { Info, Plugin } from 'modern-errors' 2 | import { expectAssignable, expectNotAssignable } from 'tsd' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 5 | const name = 'test' as const 6 | 7 | expectAssignable({ 8 | name, 9 | staticMethods: { 10 | staticMethod: (info: Info['staticMethods'], one: '', two: '') => '', 11 | }, 12 | }) 13 | expectAssignable({ name, staticMethods: {} }) 14 | expectNotAssignable({ name, staticMethods: true }) 15 | expectNotAssignable({ name, staticMethods: { staticMethod: true } }) 16 | expectNotAssignable({ 17 | name, 18 | staticMethods: { staticMethod: (info: true) => '' }, 19 | }) 20 | expectNotAssignable({ 21 | name, 22 | staticMethods: { staticMethod: (info: { one: '' }) => '' }, 23 | }) 24 | expectNotAssignable({ 25 | name, 26 | staticMethods: { staticMethod: (info: Info['properties']) => '' }, 27 | }) 28 | expectNotAssignable({ 29 | name, 30 | staticMethods: { staticMethod: (info: Info['instanceMethods']) => '' }, 31 | }) 32 | -------------------------------------------------------------------------------- /src/subclass/validate.js: -------------------------------------------------------------------------------- 1 | import { classesData } from './map.js' 2 | 3 | // We forbid subclasses that are not known, i.e. not passed to 4 | // `ErrorClass.subclass()` 5 | // - They would not be validated at load time 6 | // - The class would not be normalized until its first instantiation 7 | // - E.g. its `prototype.name` might be missing 8 | // - The list of `ErrorClasses` would be potentially incomplete 9 | // - E.g. `ErrorClass.parse()` would not be able to parse an error class 10 | // until its first instantiation 11 | // This usually happens if a class was: 12 | // - Not passed to the `custom` option of `*Error.subclass()` 13 | // - But was extended from a known class 14 | export const validateSubclass = (ErrorClass) => { 15 | if (classesData.has(ErrorClass)) { 16 | return 17 | } 18 | 19 | const { name } = ErrorClass 20 | const { name: parentName } = Object.getPrototypeOf(ErrorClass) 21 | throw new Error( 22 | `"new ${name}()" must not be directly called. 23 | This error class should be created like this instead: 24 | export const ${name} = ${parentName}.subclass('${name}')`, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/options/clone.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorSubclasses } from '../helpers/plugin.test.js' 5 | 6 | each(ErrorSubclasses, ({ title }, ErrorClass) => { 7 | test(`Options can be symbols | ${title}`, (t) => { 8 | const symbol = Symbol('test') 9 | t.true( 10 | new ErrorClass('test', { prop: { [symbol]: true } }).properties.options 11 | .prop[symbol], 12 | ) 13 | }) 14 | 15 | test(`Options can be non-enumerable | ${title}`, (t) => { 16 | const { options } = new ErrorClass('test', { 17 | // eslint-disable-next-line fp/no-mutating-methods 18 | prop: Object.defineProperty({}, 'one', { 19 | value: true, 20 | enumerable: false, 21 | writable: true, 22 | configurable: true, 23 | }), 24 | }).properties 25 | t.true(options.prop.one) 26 | t.false(Object.getOwnPropertyDescriptor(options.prop, 'one').enumerable) 27 | }) 28 | 29 | test(`Options can be arrays | ${title}`, (t) => { 30 | t.true( 31 | new ErrorClass('test', { prop: [{ one: true }] }).properties.options 32 | .prop[0].one, 33 | ) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/helpers/info.test.js: -------------------------------------------------------------------------------- 1 | // Retrieve `info` passed to `plugin.properties()` 2 | export const getPropertiesInfo = (ErrorClass, instanceOpts) => 3 | new ErrorClass('test', instanceOpts).properties 4 | 5 | // Retrieve `info` passed to `error.*(...)` 6 | export const getInstanceInfo = (ErrorClass, instanceOpts, methodOpts) => 7 | new ErrorClass('test', instanceOpts).getInstance(methodOpts) 8 | 9 | // Retrieve `info` passed to `ErrorClass.*(error, ...)` 10 | export const getMixInfo = (ErrorClass, instanceOpts, methodOpts) => 11 | ErrorClass.getInstance(new ErrorClass('test', instanceOpts), methodOpts) 12 | 13 | // Retrieve `info` passed to `ErrorClass.*(...)` 14 | export const getStaticInfo = (ErrorClass, _, methodOpts) => 15 | ErrorClass.getProp(methodOpts) 16 | 17 | // Call either instance or static methods with specific method arguments and 18 | // options 19 | export const callInstanceMethod = (ErrorClass, ...args) => 20 | new ErrorClass('message').getInstance(...args) 21 | 22 | export const callMixMethod = (ErrorClass, ...args) => 23 | ErrorClass.getInstance(new ErrorClass('message'), ...args) 24 | 25 | export const callStaticMethod = (ErrorClass, ...args) => 26 | ErrorClass.getProp(...args) 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: Please replace with a clear and descriptive title 4 | labels: [enhancement] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for suggesting a new feature! 9 | - type: checkboxes 10 | attributes: 11 | label: Guidelines 12 | options: 13 | - label: 14 | Please search other issues to make sure this feature has not already 15 | been requested. 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Which problem is this feature request solving? 20 | placeholder: I'm always frustrated when [...] 21 | validations: 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Describe the solution you'd like 26 | placeholder: This could be fixed by [...] 27 | validations: 28 | required: true 29 | - type: checkboxes 30 | attributes: 31 | label: Pull request (optional) 32 | description: 33 | Pull requests are welcome! If you would like to help us fix this bug, 34 | please check our [contributions 35 | guidelines](../blob/main/CONTRIBUTING.md). 36 | options: 37 | - label: I can submit a pull request. 38 | required: false 39 | -------------------------------------------------------------------------------- /src/subclass/custom.js: -------------------------------------------------------------------------------- 1 | import { checkCustom } from './check.js' 2 | 3 | // The `custom` option can be used to customize a specific error class. 4 | // It must extend directly from the parent class. 5 | // We use a thin child class instead of `custom` directly since this allows: 6 | // - Mutating it, e.g. its `name`, without modifying the `custom` option 7 | // - Creating several classes with the same `custom` option 8 | // `setErrorName()` also checks that `name` is a string and is not one of the 9 | // native error classes. 10 | // `custom` instance properties and errors set after instantiation can always 11 | // override any other property 12 | // - Including error core properties, `plugin.properties()`, instance|static 13 | // methods 14 | // - Reasons: 15 | // - It is not possible for `ModernError` to check its child class since it 16 | // is called afterwards 17 | // - It allows for some useful overrides like `toJSON()` 18 | // - It prevents user-defined `props` from overriding `custom` properties 19 | export const getErrorClass = (ParentError, custom) => { 20 | const ParentClass = getParentClass(ParentError, custom) 21 | return class extends ParentClass {} 22 | } 23 | 24 | const getParentClass = (ParentError, custom) => { 25 | if (custom === undefined) { 26 | return ParentError 27 | } 28 | 29 | checkCustom(custom, ParentError) 30 | return custom 31 | } 32 | -------------------------------------------------------------------------------- /examples/plugin/main.d.ts: -------------------------------------------------------------------------------- 1 | // import type { Info } from 'modern-errors' 2 | 3 | // /** 4 | // * Options of `modern-errors-example` 5 | // */ 6 | // export interface Options { 7 | // /** 8 | // * Description of `exampleOption`. 9 | // * 10 | // * @default true 11 | // */ 12 | // readonly exampleOption?: boolean 13 | // } 14 | 15 | /** 16 | * `modern-errors-example` plugin. 17 | * 18 | * Description of the plugin. 19 | */ 20 | declare const plugin: { 21 | name: 'example' 22 | 23 | // properties: (info: Info['properties']) => { exampleProp: unknown } 24 | 25 | // instanceMethods: { 26 | // /** 27 | // * Description of `ErrorClass.exampleMethod(error)`. 28 | // * 29 | // * @example 30 | // * ```js 31 | // * const value = ErrorClass.exampleMethod(error, arg) 32 | // * ``` 33 | // */ 34 | // exampleMethod: (info: Info['instanceMethods'], arg: unknown) => unknown 35 | // } 36 | 37 | // staticMethods: { 38 | // /** 39 | // * Description of `ErrorClass.staticMethod()`. 40 | // * 41 | // * @example 42 | // * ```js 43 | // * const value = ErrorClass.staticMethod(arg) 44 | // * ``` 45 | // */ 46 | // staticMethod: (info: Info['staticMethods'], arg: unknown) => unknown 47 | // } 48 | 49 | // getOptions: (input: Options, full: boolean) => Options 50 | 51 | // isOptions: (input: unknown) => boolean 52 | } 53 | export default plugin 54 | -------------------------------------------------------------------------------- /src/options/get.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { getClasses } from '../helpers/main.test.js' 5 | import { ErrorSubclasses, TEST_PLUGIN } from '../helpers/plugin.test.js' 6 | 7 | const { ErrorSubclasses: NoOptionsErrorClasses } = getClasses({ 8 | plugins: [{ ...TEST_PLUGIN, getOptions: undefined }], 9 | }) 10 | 11 | each(NoOptionsErrorClasses, ({ title }, ErrorClass) => { 12 | test(`plugin.getOptions() forbids options by default | ${title}`, (t) => { 13 | t.throws(() => new ErrorClass('test', { prop: true })) 14 | }) 15 | }) 16 | 17 | each( 18 | NoOptionsErrorClasses, 19 | [undefined, {}, { prop: undefined }], 20 | ({ title }, ErrorClass, opts) => { 21 | test(`plugin.getOptions() allows undefined options by default | ${title}`, (t) => { 22 | t.notThrows(() => new ErrorClass('test', opts)) 23 | }) 24 | }, 25 | ) 26 | 27 | each(ErrorSubclasses, ({ title }, ErrorClass) => { 28 | test(`plugin.getOptions() validate class options | ${title}`, (t) => { 29 | t.throws( 30 | ErrorClass.subclass.bind(undefined, 'TestError', { prop: 'invalid' }), 31 | { message: 'Invalid "prop" options: Invalid prop' }, 32 | ) 33 | }) 34 | 35 | test(`plugin.getOptions() validate instance options | ${title}`, (t) => { 36 | t.throws(() => new ErrorClass('test', { prop: 'invalid' }), { 37 | message: 'Invalid "prop" options: Invalid prop', 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/utils/subclass.js: -------------------------------------------------------------------------------- 1 | // Check if `ErrorClass` is a subclass of `ParentClass`. 2 | // We encourage `instanceof` over `error.name` for checking since this: 3 | // - Prevents name collisions with other libraries 4 | // - Allows checking if any error came from a given library 5 | // - Includes error classes in the exported interface explicitly instead of 6 | // implicitly, so that users are mindful about breaking changes 7 | // - Bundles classes with TypeScript documentation, types and autocompletion 8 | // - Encourages documenting error types 9 | // Checking class with `error.name` is still supported, but not documented 10 | // - Since it is widely used and can be better in specific cases 11 | // This also provides with namespacing, i.e. prevents classes of the same name 12 | // but in different libraries to be considered equal. As opposed to the 13 | // following alternatives: 14 | // - Namespacing all error names with a common prefix since this: 15 | // - Leads to verbose error names 16 | // - Requires either an additional option, or guessing ambiguously whether 17 | // error names are meant to include a namespace prefix 18 | // - Using a separate `namespace` property: this adds too much complexity and 19 | // is less standard than `instanceof` 20 | export const isSubclass = (ErrorClass, ParentClass) => 21 | ParentClass === ErrorClass || isProtoOf.call(ParentClass, ErrorClass) 22 | 23 | const { isPrototypeOf: isProtoOf } = Object.prototype 24 | -------------------------------------------------------------------------------- /src/plugins/properties/assign.js: -------------------------------------------------------------------------------- 1 | import { excludeKeys } from 'filter-obj' 2 | import setErrorMessage from 'set-error-message' 3 | import setErrorProps from 'set-error-props' 4 | import setErrorStack from 'set-error-stack' 5 | 6 | import { getPluginsMethodNames } from '../instance/main.js' 7 | 8 | // `plugin.properties()` returns an object of properties to set. 9 | // `undefined` values delete properties. 10 | // Those are shallowly merged. 11 | // Many properties are ignored: 12 | // - error core properties (except for `message` and `stack`) 13 | // - reserved top-level properties like `wrap` 14 | // - instance methods 15 | // Setting those does not throw since: 16 | // - `plugin.properties()`'s return value might be dynamically generated 17 | // making it cumbersome for user to filter those. 18 | // - Throwing errors at runtime should be done with care since this would 19 | // happen during error handling time 20 | export const assignError = ( 21 | error, 22 | { message, stack, ...newProps }, 23 | plugins, 24 | ) => { 25 | if (stack !== undefined) { 26 | setErrorStack(error, stack) 27 | } 28 | 29 | if (message !== undefined) { 30 | setErrorMessage(error, message) 31 | } 32 | 33 | if (Reflect.ownKeys(newProps).length === 0) { 34 | return 35 | } 36 | 37 | const keys = excludeKeys(newProps, [ 38 | ...OMITTED_PROPS, 39 | ...getPluginsMethodNames(plugins), 40 | ]) 41 | setErrorProps(error, keys) 42 | } 43 | 44 | const OMITTED_PROPS = ['wrap', 'constructorArgs'] 45 | -------------------------------------------------------------------------------- /src/options/clone.js: -------------------------------------------------------------------------------- 1 | import isPlainObj from 'is-plain-obj' 2 | 3 | // Deep clone an object except for class instances. 4 | // This is done on: 5 | // - Plugins options before storing them for later usage: 6 | // - For class options and error instance options 7 | // - So that, if user mutates them, this does not change the options used 8 | // internally 9 | // - Most arguments passed to `plugin.*()` methods: 10 | // - This prevents mutations by plugins from impacting the logic 11 | // - This also allows exposing plugin methods arguments to users 12 | // - E.g. by returning them or setting them to `error.*` 13 | // - Without any risk for mutations to impact the logic 14 | // - E.g. shared `props` can be set and mutated on each error instance 15 | // without propagating to other instances 16 | export const deepClone = (value) => { 17 | if (Array.isArray(value)) { 18 | return value.map(deepClone) 19 | } 20 | 21 | if (isPlainObj(value)) { 22 | return deepCloneObject(value) 23 | } 24 | 25 | return value 26 | } 27 | 28 | const deepCloneObject = (object) => { 29 | const copy = {} 30 | 31 | // eslint-disable-next-line fp/no-loops 32 | for (const key of Reflect.ownKeys(object)) { 33 | const descriptor = Object.getOwnPropertyDescriptor(object, key) 34 | const childCopy = deepClone(descriptor.value) 35 | // eslint-disable-next-line fp/no-mutating-methods 36 | Object.defineProperty(copy, key, { ...descriptor, value: childCopy }) 37 | } 38 | 39 | return copy 40 | } 41 | -------------------------------------------------------------------------------- /src/plugins/properties/main.js: -------------------------------------------------------------------------------- 1 | import isPlainObj from 'is-plain-obj' 2 | 3 | import { getErrorPluginInfo } from '../info/main.js' 4 | 5 | import { assignError } from './assign.js' 6 | 7 | // Set each `plugin.properties()`. 8 | // A `reduce()` function is used so that plugins can override the same 9 | // properties, e.g. `message` or `stack`. 10 | // Since `plugin.properties()` is called at error initialization time and might 11 | // be called later again with newly merged options, plugins should try to 12 | // make that later call behave as if the first did not happen. 13 | // - If `options` are not an object, this means calling `properties(optionsA)` 14 | // then `properties(optionsB)` should be the same as just calling 15 | // `properties(optionsB)` 16 | // - If `options` are an object, the same applies but with 17 | // `{ ...optionsA, ...optionsB }` 18 | export const setPluginsProperties = (error, plugins) => 19 | plugins.forEach((plugin) => { 20 | applyPluginProperties({ error, plugin, plugins }) 21 | }) 22 | 23 | const applyPluginProperties = ({ 24 | error, 25 | plugin, 26 | plugin: { properties, fullName }, 27 | plugins, 28 | }) => { 29 | if (properties === undefined) { 30 | return 31 | } 32 | 33 | const info = getErrorPluginInfo({ error, plugins, plugin }) 34 | const newProps = properties(info) 35 | 36 | if (!isPlainObj(newProps)) { 37 | throw new TypeError( 38 | `Plugin "${fullName}"'s "properties()" must return a plain object: ${newProps}`, 39 | ) 40 | } 41 | 42 | assignError(error, newProps, plugins) 43 | } 44 | -------------------------------------------------------------------------------- /src/options/class.js: -------------------------------------------------------------------------------- 1 | import isPlainObj from 'is-plain-obj' 2 | 3 | import { validatePluginsOptsNames } from '../plugins/shape/name.js' 4 | 5 | import { deepClone } from './clone.js' 6 | import { getPluginOpts } from './get.js' 7 | import { mergePluginsOpts } from './merge.js' 8 | 9 | // Simple validation and normalization of class options 10 | export const normalizeClassOpts = (ParentError, classOpts = {}) => { 11 | if (!isPlainObj(classOpts)) { 12 | throw new TypeError( 13 | `The second argument of "${ParentError.name}.subclass()" must be a plain object, not: ${classOpts}`, 14 | ) 15 | } 16 | 17 | return classOpts 18 | } 19 | 20 | // Validate and compute class options as soon as the class is created. 21 | // Merging priority is: parent class < child class < instance < method 22 | // We encourages plugins to use class options 23 | // - Including global ones, i.e. on the top-level class 24 | // - As opposed to alternatives: 25 | // - Using functions that take options as argument and return a plugin 26 | // - Passing options as arguments to instance|static methods 27 | // - To ensure: 28 | // - A consistent, single way of configuring plugins 29 | // - Options can be specified at different levels 30 | export const getClassOpts = (parentOpts, classOpts, plugins) => { 31 | validatePluginsOptsNames(classOpts, plugins) 32 | const classOptsA = mergePluginsOpts(parentOpts, classOpts, plugins) 33 | const classOptsB = deepClone(classOptsA) 34 | plugins.forEach((plugin) => { 35 | getPluginOpts({ pluginsOpts: classOptsB, plugin, full: false }) 36 | }) 37 | return classOptsB 38 | } 39 | -------------------------------------------------------------------------------- /src/merge/aggregate.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses, ModernError } from '../helpers/main.test.js' 5 | 6 | each(ErrorClasses, ({ title }, ErrorClass) => { 7 | test(`error.errors can be set | ${title}`, (t) => { 8 | t.deepEqual(new ErrorClass('test', { errors: [] }).errors, []) 9 | }) 10 | 11 | test(`error.errors is validated | ${title}`, (t) => { 12 | t.throws(() => new ErrorClass('test', { errors: true })) 13 | }) 14 | 15 | test(`error.errors is not enumerable | ${title}`, (t) => { 16 | t.false( 17 | Object.getOwnPropertyDescriptor( 18 | new ErrorClass('test', { errors: [] }), 19 | 'errors', 20 | ).enumerable, 21 | ) 22 | }) 23 | 24 | test(`error.errors are normalized | ${title}`, (t) => { 25 | const [error] = new ErrorClass('test', { errors: [true] }).errors 26 | t.true(error instanceof Error) 27 | t.false(error instanceof ModernError) 28 | }) 29 | 30 | test(`error.errors are appended to | ${title}`, (t) => { 31 | const one = new ErrorClass('one') 32 | const two = new ErrorClass('two') 33 | const cause = new ErrorClass('causeMessage', { errors: [one] }) 34 | const error = new ErrorClass('message', { cause, errors: [two] }) 35 | t.deepEqual(error.errors, [one, two]) 36 | }) 37 | }) 38 | 39 | each( 40 | ErrorClasses, 41 | [undefined, {}, { errors: undefined }], 42 | ({ title }, ErrorClass, opts) => { 43 | test(`error.errors are not set by default | ${title}`, (t) => { 44 | t.false('errors' in new ErrorClass('test', opts)) 45 | }) 46 | }, 47 | ) 48 | -------------------------------------------------------------------------------- /src/subclass/custom.d.ts: -------------------------------------------------------------------------------- 1 | import type { AggregateErrors } from '../merge/aggregate.js' 2 | import type { 3 | Cause, 4 | InstanceOptions, 5 | SpecificInstanceOptions, 6 | } from '../options/instance.js' 7 | import type { ErrorProps } from '../plugins/core/props/main.js' 8 | import type { Plugins } from '../plugins/shape/main.js' 9 | 10 | /** 11 | * `custom` option 12 | * 13 | * @private This type is private and only exported as a temporary workaround 14 | * for an open issue with TypeScript. It will be removed in a future release. 15 | * See: 16 | * 17 | * - [modern-errors issue #18](https://github.com/ehmicky/modern-errors/issues/18) 18 | * - [TypeScript issue #47663](https://github.com/microsoft/TypeScript/issues/47663) 19 | */ 20 | export interface CustomClass { 21 | new (message: string, options?: InstanceOptions): Error 22 | subclass: unknown 23 | } 24 | 25 | /** 26 | * Second argument of the `constructor` of the parent error class 27 | */ 28 | export type ParentInstanceOptions< 29 | PluginsArg extends Plugins, 30 | ChildProps extends ErrorProps, 31 | CustomClassArg extends CustomClass, 32 | AggregateErrorsArg extends AggregateErrors, 33 | CauseArg extends Cause, 34 | > = ConstructorParameters[1] & 35 | SpecificInstanceOptions 36 | 37 | /** 38 | * Last variadic arguments of the `constructor` of the parent error class 39 | */ 40 | export type ParentExtra = 41 | ConstructorParameters extends readonly [ 42 | unknown, 43 | unknown?, 44 | ...infer Extra extends readonly unknown[], 45 | ] 46 | ? Extra 47 | : readonly never[] 48 | -------------------------------------------------------------------------------- /src/plugins/instance/main.js: -------------------------------------------------------------------------------- 1 | import { setNonEnumProp } from '../../utils/descriptors.js' 2 | 3 | import { callInstanceMethod } from './call.js' 4 | import { callMixedMethod } from './mixed.js' 5 | 6 | // Plugins can define an `instanceMethods` object, which is merged to 7 | // `ErrorClass.prototype.*`. 8 | export const addAllInstanceMethods = (plugins, ErrorClass) => { 9 | plugins.forEach((plugin) => { 10 | addInstanceMethods(plugin, plugins, ErrorClass) 11 | }) 12 | } 13 | 14 | const addInstanceMethods = (plugin, plugins, ErrorClass) => { 15 | Object.entries(plugin.instanceMethods).forEach( 16 | addInstanceMethod.bind(undefined, { plugin, plugins, ErrorClass }), 17 | ) 18 | } 19 | 20 | const addInstanceMethod = ( 21 | { plugin, plugins, ErrorClass }, 22 | [methodName, methodFunc], 23 | ) => { 24 | setNonEnumProp( 25 | ErrorClass.prototype, 26 | methodName, 27 | function boundInstanceMethod(...args) { 28 | return callInstanceMethod({ 29 | // eslint-disable-next-line fp/no-this, no-invalid-this 30 | error: this, 31 | methodFunc, 32 | methodName, 33 | plugin, 34 | plugins, 35 | ErrorClass, 36 | args, 37 | }) 38 | }, 39 | ) 40 | setNonEnumProp( 41 | ErrorClass, 42 | methodName, 43 | callMixedMethod.bind(undefined, { 44 | methodFunc, 45 | plugin, 46 | plugins, 47 | ErrorClass, 48 | }), 49 | ) 50 | } 51 | 52 | // Retrieve the name of all instance methods 53 | export const getPluginsMethodNames = (plugins) => 54 | plugins.flatMap(getPluginMethodNames) 55 | 56 | const getPluginMethodNames = ({ instanceMethods }) => 57 | Object.keys(instanceMethods) 58 | -------------------------------------------------------------------------------- /src/merge/cause.d.ts: -------------------------------------------------------------------------------- 1 | import type { Cause, NormalizedCause } from '../options/instance.js' 2 | import type { ErrorProps } from '../plugins/core/props/main.js' 3 | import type { PluginsInstanceMethods } from '../plugins/instance/call.js' 4 | import type { PluginsProperties } from '../plugins/properties/main.js' 5 | import type { Plugins } from '../plugins/shape/main.js' 6 | import type { CustomClass } from '../subclass/custom.js' 7 | import type { SetProps } from '../utils/omit.js' 8 | 9 | import type { AggregateErrors, AggregateErrorsProperty } from './aggregate.js' 10 | 11 | /** 12 | * Error instance object, used internally with additional generics. 13 | * This mixes: `Error`, aggregate errors, plugin instance methods, 14 | * `plugin.properties()` and `props`, while ensuring those do not overlap each 15 | * other. 16 | */ 17 | export type SpecificErrorInstance< 18 | PluginsArg extends Plugins, 19 | ErrorPropsArg extends ErrorProps, 20 | CustomClassArg extends CustomClass, 21 | AggregateErrorsArg extends AggregateErrors, 22 | CauseArg extends Cause, 23 | > = SetProps< 24 | NormalizedCause, 25 | SetProps< 26 | ErrorPropsArg, 27 | SetProps< 28 | PluginsProperties, 29 | SetProps< 30 | PluginsInstanceMethods, 31 | SetProps< 32 | InstanceType, 33 | AggregateErrorsProperty 34 | > 35 | > 36 | > 37 | > 38 | > 39 | 40 | /** 41 | * Error instance object 42 | */ 43 | export type ErrorInstance = 44 | SpecificErrorInstance< 45 | PluginsArg, 46 | ErrorProps, 47 | CustomClass, 48 | AggregateErrors, 49 | Cause 50 | > 51 | -------------------------------------------------------------------------------- /src/plugins/info/main.js: -------------------------------------------------------------------------------- 1 | import { instancesData } from '../../subclass/map.js' 2 | 3 | import { getAnyErrorInfo, getKnownErrorInfo, getSubclasses } from './error.js' 4 | 5 | // Retrieve `info` passed to `plugin.properties|instanceMethods`, but not 6 | // `staticMethods` since it does not have access to an error. 7 | export const getErrorPluginInfo = ({ error, methodOpts, plugins, plugin }) => { 8 | const { ErrorClass, options } = getKnownErrorInfo({ 9 | error, 10 | methodOpts, 11 | plugins, 12 | plugin, 13 | }) 14 | const info = getPluginInfo({ 15 | options, 16 | ErrorClass, 17 | methodOpts, 18 | plugins, 19 | plugin, 20 | }) 21 | // eslint-disable-next-line fp/no-mutating-assign 22 | Object.assign(info, { error }) 23 | return info 24 | } 25 | 26 | // Retrieve `info` passed to `plugin.properties|instanceMethods|staticMethods` 27 | export const getPluginInfo = ({ 28 | options, 29 | ErrorClass, 30 | methodOpts, 31 | plugins, 32 | plugin, 33 | }) => { 34 | const errorInfo = getAnyErrorInfo.bind(undefined, { 35 | ErrorClass, 36 | methodOpts, 37 | plugins, 38 | plugin, 39 | }) 40 | const ErrorClasses = getSubclasses(ErrorClass) 41 | const info = { options, ErrorClass, ErrorClasses, errorInfo } 42 | setInstancesData(info) 43 | return info 44 | } 45 | 46 | // `instancesData` is internal, undocumented and non-enumerable. 47 | // It is only needed in very specific plugins like `modern-errors-serialize` 48 | const setInstancesData = (info) => { 49 | // eslint-disable-next-line fp/no-mutating-methods 50 | Object.defineProperty(info, 'instancesData', { 51 | value: instancesData, 52 | enumerable: false, 53 | writable: true, 54 | configurable: true, 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /src/options/merge.js: -------------------------------------------------------------------------------- 1 | import isPlainObj from 'is-plain-obj' 2 | 3 | // Merge: 4 | // - child class options with parent class options 5 | // - class options with instance options 6 | // - method options with other options 7 | // The same logic is used between those, for consistency. 8 | // For class options, this is done as late as possible to ensure `instancesData` 9 | // only contains instance options. 10 | // `undefined` values are ignored, even if the key is set because: 11 | // - This might be due to conditional logic 12 | // - Differentiating between `undefined` and "not defined" values is confusing 13 | // Object options are shallowly merged. 14 | // The merging logic enforces the idea that information should only be appended, 15 | // to ensure that no error information is lost 16 | // - E.g. there is no direct way to unset options 17 | // - However, this can be achieved, e.g. by moving parent class options 18 | // to their subclasses 19 | export const mergePluginsOpts = (oldOpts, newOpts, plugins) => 20 | Object.fromEntries( 21 | getPluginNames(plugins) 22 | .map((name) => mergePluginOpts(oldOpts, newOpts, name)) 23 | .filter(Boolean), 24 | ) 25 | 26 | const mergePluginOpts = (oldOpts, newOpts, name) => { 27 | const pluginOpt = mergeOpt(oldOpts[name], newOpts[name]) 28 | return pluginOpt === undefined ? undefined : [name, pluginOpt] 29 | } 30 | 31 | const mergeOpt = (oldOpt, newOpt) => { 32 | if (newOpt === undefined) { 33 | return oldOpt 34 | } 35 | 36 | if (isPlainObj(oldOpt) && isPlainObj(newOpt)) { 37 | return { ...oldOpt, ...newOpt } 38 | } 39 | 40 | return newOpt 41 | } 42 | 43 | export const getPluginNames = (plugins) => plugins.map(getPluginName) 44 | 45 | const getPluginName = ({ name }) => name 46 | -------------------------------------------------------------------------------- /examples/plugin/main.test-d.ts: -------------------------------------------------------------------------------- 1 | import ModernError from 'modern-errors' 2 | import modernErrorsExample, { type Options } from 'modern-errors-example' 3 | import { expectType, expectAssignable, expectNotAssignable } from 'tsd' 4 | 5 | // Check the plugin shape by passing it to `modernErrors()` 6 | const BaseError = ModernError.subclass('BaseError', { 7 | plugins: [modernErrorsExample], 8 | }) 9 | const error = new BaseError('') 10 | 11 | // Check `plugin.properties()` 12 | expectType(error.exampleProp) 13 | // @ts-expect-error 14 | error.unknownProp 15 | 16 | // Check `plugin.instanceMethods` 17 | expectType(BaseError.exampleMethod(error, 'validArgument')) 18 | // @ts-expect-error 19 | BaseError.exampleMethod(error, 'invalidArgument') 20 | 21 | // Check `plugin.staticMethods` 22 | expectType(BaseError.staticMethod('validArgument')) 23 | // @ts-expect-error 24 | BaseError.staticMethod('invalidArgument') 25 | 26 | // Check `plugin.getOptions()`, `plugin.isOptions()` and `plugin.name` 27 | ModernError.subclass('TestError', { 28 | plugins: [modernErrorsExample], 29 | exampleOption: 'validOption', 30 | }) 31 | BaseError.exampleMethod(error, 'validArgument', { 32 | exampleOption: 'validOption', 33 | }) 34 | BaseError.staticMethod('validArgument', { exampleOption: 'validOption' }) 35 | expectAssignable({ exampleOption: 'validOption' }) 36 | // @ts-expect-error 37 | ModernError.subclass('TestError', { 38 | plugins: [modernErrorsExample], 39 | exampleOption: 'invalidOption', 40 | }) 41 | // @ts-expect-error 42 | BaseError.exampleMethod(error, 'validArgument', { 43 | exampleOption: 'invalidOption', 44 | }) 45 | // @ts-expect-error 46 | BaseError.staticMethod('validArgument', { exampleOption: 'invalidOption' }) 47 | expectNotAssignable({ exampleOption: 'invalidOption' }) 48 | -------------------------------------------------------------------------------- /src/merge/cause.js: -------------------------------------------------------------------------------- 1 | import isErrorInstance from 'is-error-instance' 2 | import mergeErrorCause from 'merge-error-cause' 3 | 4 | import { isSubclass } from '../utils/subclass.js' 5 | 6 | import { prefixCause, shouldPrefixCause, undoPrefixCause } from './prefix.js' 7 | 8 | // Like `mergeCause()` but run outside of `new ErrorClass(...)` 9 | export const mergeSpecificCause = (error, cause) => { 10 | error.cause = cause 11 | error.wrap = true 12 | return mergeErrorCause(error) 13 | } 14 | 15 | // `error.cause` is merged as soon as the error is instantiated: 16 | // - This allow benefitting from `cause` merging right away, e.g. for improved 17 | // debugging 18 | // - This is simpler as it avoids the error shape to change over its lifetime 19 | // `error`'s class is used over `error.cause`'s since users expect the instance 20 | // class to match the constructor being used. 21 | // However, if `error.cause` has the same class or a child class, we keep it 22 | // instead 23 | // - This allows using `new BaseError(...)` to wrap an error without changing 24 | // its class 25 | // - This returns a subclass of the parent class, which does not break 26 | // inheritance nor user expectations 27 | export const mergeCause = (error, ErrorClass) => { 28 | if (!isErrorInstance(error.cause)) { 29 | return mergeErrorCause(error) 30 | } 31 | 32 | error.wrap = isSubclass(error.cause.constructor, ErrorClass) 33 | 34 | if (!shouldPrefixCause(error, ErrorClass)) { 35 | return mergeErrorCause(error) 36 | } 37 | 38 | return mergePrefixedError(error) 39 | } 40 | 41 | const mergePrefixedError = (error) => { 42 | const { cause } = error 43 | const oldMessage = prefixCause(cause) 44 | const errorA = mergeErrorCause(error) 45 | 46 | if (cause !== errorA) { 47 | undoPrefixCause(cause, oldMessage) 48 | } 49 | 50 | return errorA 51 | } 52 | -------------------------------------------------------------------------------- /src/plugins/shape/name.js: -------------------------------------------------------------------------------- 1 | // Ensure class options: 2 | // - Only contain plugins options 3 | // - Do not refer to unloaded plugins 4 | export const validatePluginsOptsNames = (pluginsOpts, plugins) => { 5 | Object.entries(pluginsOpts).forEach(([optName, pluginOpts]) => { 6 | validatePluginOptsName(optName, pluginOpts, plugins) 7 | }) 8 | } 9 | 10 | const validatePluginOptsName = (optName, pluginOpts, plugins) => { 11 | if (!plugins.some(({ name }) => name === optName)) { 12 | throw new TypeError( 13 | `Invalid option "${optName}": the plugin "${NAME_PREFIX}${optName}" must be passed to ErrorClass.subclass("...", { plugins })`, 14 | ) 15 | } 16 | } 17 | 18 | // Validate `plugin.name` 19 | export const validatePluginName = (plugin) => { 20 | if (plugin.name === undefined) { 21 | throw new TypeError(`The plugin is missing a "name": ${plugin}`) 22 | } 23 | 24 | const { name } = plugin 25 | 26 | if (typeof name !== 'string') { 27 | throw new TypeError(`The plugin "name" must be a string: ${name}`) 28 | } 29 | 30 | validateNameString(name) 31 | 32 | return { ...plugin, fullName: `${NAME_PREFIX}${name}` } 33 | } 34 | 35 | const validateNameString = (name) => { 36 | if (FORBIDDEN_NAMES.has(name)) { 37 | throw new TypeError( 38 | `The plugin "name" must not be the following reserved word: ${name}`, 39 | ) 40 | } 41 | 42 | if (!NAME_REGEXP.test(name)) { 43 | throw new TypeError( 44 | `The plugin "name" must only contain lowercase letters and digits: ${name}`, 45 | ) 46 | } 47 | } 48 | 49 | const FORBIDDEN_NAMES = new Set([ 50 | 'cause', 51 | 'errors', 52 | 'custom', 53 | 'wrap', 54 | 'constructorArgs', 55 | ]) 56 | const NAME_REGEXP = /^[a-z][a-z\d]*$/u 57 | 58 | // Plugin package names should start with this prefix, but not `plugin.name` 59 | const NAME_PREFIX = 'modern-errors-' 60 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_types.yml: -------------------------------------------------------------------------------- 1 | name: Bug report (TypeScript types) 2 | description: Report a bug about TypeScript types 3 | title: Please replace with a clear and descriptive title 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for reporting this bug! 9 | - type: checkboxes 10 | attributes: 11 | label: Guidelines 12 | options: 13 | - label: 14 | Please search other issues to make sure this bug has not already 15 | been reported. 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Describe the bug 20 | placeholder: A clear and concise description of what the bug is. 21 | validations: 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Steps to reproduce 26 | description: | 27 | Please reproduce the bug using the [TypeScript playground](https://www.typescriptlang.org/play) or [Bug workbench](https://www.typescriptlang.org/dev/bug-workbench), then paste the URL here. 28 | validations: 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: Environment 33 | description: | 34 | Enter the following command in a terminal and copy/paste its output: 35 | ```bash 36 | npx envinfo --system --binaries --browsers --npmPackages modern-errors,typescript --npmGlobalPackages typescript 37 | ``` 38 | validations: 39 | required: true 40 | - type: checkboxes 41 | attributes: 42 | label: Pull request (optional) 43 | description: 44 | Pull requests are welcome! If you would like to help us fix this bug, 45 | please check our [contributions 46 | guidelines](../blob/main/CONTRIBUTING.md). 47 | options: 48 | - label: I can submit a pull request. 49 | required: false 50 | -------------------------------------------------------------------------------- /src/plugins/static/call.d.ts: -------------------------------------------------------------------------------- 1 | import type { MethodOptions } from '../../options/method.js' 2 | import type { UnionToIntersection } from '../../utils/intersect.js' 3 | import type { SliceFirst } from '../../utils/slice.js' 4 | import type { Plugin, Plugins } from '../shape/main.js' 5 | 6 | import type { StaticMethod, StaticMethods } from './main.js' 7 | 8 | /** 9 | * Bound static method parameters 10 | */ 11 | type ErrorStaticMethodParams< 12 | StaticMethodArg extends StaticMethod, 13 | MethodOptionsArg extends MethodOptions, 14 | > = 15 | | SliceFirst> 16 | | ([MethodOptionsArg] extends [never] 17 | ? never 18 | : readonly [...SliceFirst>, MethodOptionsArg]) 19 | 20 | /** 21 | * Bound static method of a plugin 22 | */ 23 | type ErrorStaticMethod< 24 | StaticMethodArg extends StaticMethod, 25 | MethodOptionsArg extends MethodOptions, 26 | > = ( 27 | ...args: ErrorStaticMethodParams 28 | ) => ReturnType 29 | 30 | /** 31 | * Bound static methods of a plugin, always defined 32 | */ 33 | type ErrorStaticMethods< 34 | StaticMethodsArg extends StaticMethods, 35 | MethodOptionsArg extends MethodOptions, 36 | > = { 37 | readonly [MethodName in keyof StaticMethodsArg]: ErrorStaticMethod< 38 | StaticMethodsArg[MethodName], 39 | MethodOptionsArg 40 | > 41 | } 42 | 43 | /** 44 | * Bound static methods of a plugin, if defined 45 | */ 46 | type PluginStaticMethods = PluginArg extends { 47 | staticMethods: StaticMethods 48 | } 49 | ? ErrorStaticMethods> 50 | : object 51 | 52 | /** 53 | * Bound static methods of all plugins 54 | */ 55 | export type PluginsStaticMethods = 56 | UnionToIntersection> & {} 57 | -------------------------------------------------------------------------------- /src/subclass/create.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses } from '../helpers/main.test.js' 5 | 6 | each(ErrorClasses, ({ title }, ErrorClass) => { 7 | test(`Does not modify invalid classes | ${title}`, (t) => { 8 | class custom extends Object {} 9 | t.throws(ErrorClass.subclass.bind(undefined, 'TestError', { custom })) 10 | t.false('name' in custom.prototype) 11 | }) 12 | 13 | test(`ErrorClass.name is correct | ${title}`, (t) => { 14 | t.not(ErrorClass.name, '') 15 | }) 16 | 17 | test(`prototype.name is correct | ${title}`, (t) => { 18 | t.is(ErrorClass.prototype.name, ErrorClass.name) 19 | t.false( 20 | Object.getOwnPropertyDescriptor(ErrorClass.prototype, 'name').enumerable, 21 | ) 22 | }) 23 | 24 | test(`error.name is correct | ${title}`, (t) => { 25 | const error = new ErrorClass('test') 26 | t.false(Object.hasOwn(error, 'name')) 27 | t.is(error.name, ErrorClass.name) 28 | }) 29 | 30 | test(`Allows duplicate names | ${title}`, (t) => { 31 | t.is(ErrorClass.subclass(ErrorClass.name).name, ErrorClass.name) 32 | }) 33 | 34 | test(`Core static methods are not enumerable | ${title}`, (t) => { 35 | t.deepEqual(Object.keys(ErrorClass), []) 36 | }) 37 | 38 | test(`ErrorClass.subclass() context is bound | ${title}`, (t) => { 39 | const { subclass } = ErrorClass 40 | const TestError = subclass('TestError') 41 | t.true(new TestError('message') instanceof TestError) 42 | }) 43 | }) 44 | 45 | each( 46 | ErrorClasses, 47 | [ 48 | undefined, 49 | '', 50 | {}, 51 | 'Error', 52 | 'TypeError', 53 | 'inputError', 54 | 'input_error', 55 | 'input', 56 | ], 57 | ({ title }, ErrorClass, errorName) => { 58 | test(`Validate invalid error name | ${title}`, (t) => { 59 | t.throws(ErrorClass.subclass.bind(undefined, errorName)) 60 | }) 61 | }, 62 | ) 63 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributions 2 | 3 | 🎉 Thanks for considering contributing to this project! 🎉 4 | 5 | These guidelines will help you send a pull request. 6 | 7 | If you're submitting an issue instead, please skip this document. 8 | 9 | If your pull request is related to a typo or the documentation being unclear, 10 | please click on the relevant page's `Edit` button (pencil icon) and directly 11 | suggest a correction instead. 12 | 13 | This project was made with ❤️. The simplest way to give back is by starring and 14 | sharing it online. 15 | 16 | Everyone is welcome regardless of personal background. We enforce a 17 | [Code of conduct](CODE_OF_CONDUCT.md) in order to promote a positive and 18 | inclusive environment. 19 | 20 | # Development process 21 | 22 | First fork and clone the repository. If you're not sure how to do this, please 23 | watch 24 | [these videos](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github). 25 | 26 | Run: 27 | 28 | ```bash 29 | npm install 30 | ``` 31 | 32 | Make sure everything is correctly setup with: 33 | 34 | ```bash 35 | npm test 36 | ``` 37 | 38 | We use Gulp tasks to lint, test and build this project. Please check 39 | [dev-tasks](https://github.com/ehmicky/dev-tasks/blob/main/README.md) to learn 40 | how to use them. You don't need to know Gulp to use these tasks. 41 | 42 | # Requirements 43 | 44 | Our coding style is documented 45 | [here](https://github.com/ehmicky/eslint-config#coding-style). Linting and 46 | formatting should automatically handle it though. 47 | 48 | After submitting the pull request, please make sure the Continuous Integration 49 | checks are passing. 50 | 51 | We enforce 100% test coverage: each line of code must be tested. 52 | 53 | New options, methods, properties, configuration and behavior must be documented 54 | in all of these: 55 | 56 | - the `README.md` 57 | - the `docs` directory (if any) 58 | 59 | Please use the same style as the rest of the documentation and examples. 60 | -------------------------------------------------------------------------------- /src/plugins/properties/assign.test-d.ts: -------------------------------------------------------------------------------- 1 | import ModernError, { type Info, type Plugin } from 'modern-errors' 2 | import { expectType } from 'tsd' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 5 | const name = 'test' as const 6 | const emptyPlugin = { name } 7 | const fullPlugin = { 8 | ...emptyPlugin, 9 | properties: (info: Info['properties']) => ({ property: true }) as const, 10 | } 11 | 12 | const BaseError = ModernError.subclass('BaseError', { plugins: [fullPlugin] }) 13 | const MixBaseError = ModernError.subclass('MixBaseError', { 14 | plugins: [emptyPlugin, fullPlugin] as const, 15 | }) 16 | const ChildError = BaseError.subclass('ChildError') 17 | const MixChildError = MixBaseError.subclass('MixChildError') 18 | const unknownError = new BaseError('') 19 | const childError = new ChildError('') 20 | const mixUnknownError = new MixBaseError('') 21 | const mixChildError = new MixChildError('') 22 | 23 | expectType(unknownError.property) 24 | expectType(childError.property) 25 | expectType(mixUnknownError.property) 26 | expectType(mixChildError.property) 27 | 28 | const WideBaseError = ModernError.subclass('WideBaseError', { 29 | plugins: [{} as Plugin], 30 | }) 31 | const ChildWideError = WideBaseError.subclass('ChildWideError') 32 | const unknownWideError = new WideBaseError('') 33 | const childWideError = new ChildWideError('') 34 | 35 | // @ts-expect-error 36 | unknownError.otherProperty 37 | // @ts-expect-error 38 | childError.otherProperty 39 | // @ts-expect-error 40 | mixUnknownError.otherProperty 41 | // @ts-expect-error 42 | mixChildError.otherProperty 43 | // @ts-expect-error 44 | unknownWideError.otherProperty 45 | // @ts-expect-error 46 | childWideError.otherProperty 47 | 48 | const exception = {} as unknown 49 | 50 | if (exception instanceof ChildError) { 51 | expectType(exception.property) 52 | } 53 | 54 | if (exception instanceof MixChildError) { 55 | expectType(exception.property) 56 | } 57 | -------------------------------------------------------------------------------- /src/helpers/plugin.test.js: -------------------------------------------------------------------------------- 1 | import isPlainObj from 'is-plain-obj' 2 | 3 | import { getClasses } from './main.test.js' 4 | 5 | const validateContext = (context) => { 6 | if (context !== undefined) { 7 | throw new Error('Defined context') 8 | } 9 | } 10 | 11 | const addInstancesData = (info, originalInfo) => 12 | // eslint-disable-next-line fp/no-mutating-methods 13 | Object.defineProperty( 14 | info, 15 | 'instancesData', 16 | Object.getOwnPropertyDescriptor(originalInfo, 'instancesData'), 17 | ) 18 | 19 | export const TEST_PLUGIN = { 20 | name: 'prop', 21 | isOptions(prop) { 22 | // eslint-disable-next-line fp/no-this 23 | validateContext(this) 24 | return typeof prop === 'boolean' || isPlainObj(prop) 25 | }, 26 | getOptions(prop, full) { 27 | // eslint-disable-next-line fp/no-this 28 | validateContext(this) 29 | 30 | if (prop === 'invalid') { 31 | throw new TypeError('Invalid prop') 32 | } 33 | 34 | if (prop === 'partial' && full === false) { 35 | throw new TypeError('Partial') 36 | } 37 | 38 | return { prop, full } 39 | }, 40 | properties(info) { 41 | // eslint-disable-next-line fp/no-this 42 | validateContext(this) 43 | const toSet = isPlainObj(info.options?.prop) ? info.options?.prop.toSet : {} 44 | return { ...toSet, properties: addInstancesData({ ...info }, info) } 45 | }, 46 | instanceMethods: { 47 | getInstance(info, ...args) { 48 | // eslint-disable-next-line fp/no-this 49 | validateContext(this) 50 | return addInstancesData({ ...info, args }, info) 51 | }, 52 | }, 53 | staticMethods: { 54 | getProp(info, ...args) { 55 | // eslint-disable-next-line fp/no-this 56 | validateContext(this) 57 | return addInstancesData({ ...info, args }, info) 58 | }, 59 | }, 60 | } 61 | 62 | export const getPluginClasses = () => getClasses({ plugins: [TEST_PLUGIN] }) 63 | 64 | export const { ErrorClasses, ErrorSubclasses } = getPluginClasses() 65 | -------------------------------------------------------------------------------- /src/options/plugins.d.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorProps } from '../plugins/core/props/main.js' 2 | import type { Plugin, Plugins } from '../plugins/shape/main.js' 3 | 4 | /** 5 | * Options of a plugin 6 | */ 7 | export type ExternalPluginOptions = 8 | PluginArg extends Plugin 9 | ? PluginArg['getOptions'] extends NonNullable 10 | ? Parameters[0] 11 | : never 12 | : never 13 | 14 | /** 15 | * Exclude plugins with a `name` that is not typed `as const` 16 | */ 17 | type PluginOptionName = 18 | string extends PluginArg['name'] 19 | ? never 20 | : ExternalPluginOptions extends never 21 | ? never 22 | : PluginArg['name'] 23 | 24 | /** 25 | * Options of all non-core plugins 26 | */ 27 | type ExternalPluginsOptions = { 28 | readonly [PluginArg in PluginsArg[number] as PluginOptionName]?: ExternalPluginOptions 29 | } 30 | 31 | /** 32 | * Options of all core plugins 33 | */ 34 | interface CorePluginsOptions { 35 | /** 36 | * Error properties. 37 | * 38 | * @example 39 | * ```js 40 | * const error = new InputError('...', { props: { isUserError: true } }) 41 | * console.log(error.isUserError) // true 42 | * ``` 43 | * 44 | * @example 45 | * ```js 46 | * const InputError = BaseError.subclass('InputError', { 47 | * props: { isUserError: true }, 48 | * }) 49 | * const error = new InputError('...') 50 | * console.log(error.isUserError) // true 51 | * ``` 52 | */ 53 | readonly props?: ChildProps 54 | } 55 | 56 | /** 57 | * Options of all plugins, including core plugins 58 | */ 59 | export type PluginsOptions< 60 | PluginsArg extends Plugins, 61 | ChildProps extends ErrorProps, 62 | > = keyof ExternalPluginsOptions extends never 63 | ? CorePluginsOptions 64 | : CorePluginsOptions & ExternalPluginsOptions 65 | -------------------------------------------------------------------------------- /src/plugins/instance/mixed.d.ts: -------------------------------------------------------------------------------- 1 | import type { MethodOptions } from '../../options/method.js' 2 | import type { UnionToIntersection } from '../../utils/intersect.js' 3 | import type { SliceFirst } from '../../utils/slice.js' 4 | import type { Plugin, Plugins } from '../shape/main.js' 5 | 6 | import type { InstanceMethod, InstanceMethods } from './main.js' 7 | 8 | /** 9 | * Bound mixed method parameters 10 | */ 11 | type ErrorMixedMethodParams< 12 | InstanceMethodArg extends InstanceMethod, 13 | MethodOptionsArg extends MethodOptions, 14 | > = 15 | | readonly [unknown, ...SliceFirst>] 16 | | ([MethodOptionsArg] extends [never] 17 | ? never 18 | : readonly [ 19 | unknown, 20 | ...SliceFirst>, 21 | MethodOptionsArg, 22 | ]) 23 | 24 | /** 25 | * Bound mixed method of a plugin 26 | */ 27 | type ErrorMixedMethod< 28 | InstanceMethodArg extends InstanceMethod, 29 | MethodOptionsArg extends MethodOptions, 30 | > = ( 31 | ...args: ErrorMixedMethodParams 32 | ) => ReturnType 33 | 34 | /** 35 | * Bound mixed methods of a plugin, always defined 36 | */ 37 | type ErrorMixedMethods< 38 | InstanceMethodsArg extends InstanceMethods, 39 | MethodOptionsArg extends MethodOptions, 40 | > = { 41 | readonly [MethodName in keyof InstanceMethodsArg]: ErrorMixedMethod< 42 | InstanceMethodsArg[MethodName], 43 | MethodOptionsArg 44 | > 45 | } 46 | 47 | /** 48 | * Bound mixed methods of a plugin, if defined 49 | */ 50 | type PluginMixedMethods = PluginArg extends { 51 | instanceMethods: InstanceMethods 52 | } 53 | ? ErrorMixedMethods> 54 | : object 55 | 56 | /** 57 | * Bound mixed methods of all plugins 58 | */ 59 | export type PluginsMixedMethods = 60 | UnionToIntersection> & {} 61 | -------------------------------------------------------------------------------- /src/plugins/core/props/main.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses } from '../../../helpers/main.test.js' 5 | import { TEST_PLUGIN } from '../../../helpers/plugin.test.js' 6 | 7 | each(ErrorClasses, ({ title }, ErrorClass) => { 8 | test(`"props" are validated | ${title}`, (t) => { 9 | t.throws(() => new ErrorClass('message', { props: true })) 10 | }) 11 | 12 | test(`"props" are assigned | ${title}`, (t) => { 13 | t.true(new ErrorClass('message', { props: { prop: true } }).prop) 14 | }) 15 | 16 | test(`"props" have priority over cause | ${title}`, (t) => { 17 | const cause = new ErrorClass('causeMessage', { props: { prop: false } }) 18 | t.true(new ErrorClass('message', { cause, props: { prop: true } }).prop) 19 | }) 20 | 21 | test(`"props" can be used even if cause has none | ${title}`, (t) => { 22 | const cause = new ErrorClass('causeMessage') 23 | t.true(new ErrorClass('message', { cause, props: { prop: true } }).prop) 24 | }) 25 | 26 | test(`"props" are merged with cause' | ${title}`, (t) => { 27 | const propSym = Symbol('prop') 28 | const cause = new ErrorClass('causeMessage', { 29 | props: { one: true, [propSym]: true }, 30 | }) 31 | const error = new ErrorClass('message', { cause, props: { two: true } }) 32 | t.true(error[propSym]) 33 | t.true(error.one) 34 | t.true(error.two) 35 | }) 36 | 37 | test(`"props" cannot override "message" | ${title}`, (t) => { 38 | const message = 'testMessage' 39 | const error = new ErrorClass('', { props: { message } }) 40 | t.false(error.message.includes(message)) 41 | }) 42 | 43 | test(`"props" have less priority than other plugin.properties() | ${title}`, (t) => { 44 | const TestError = ErrorClass.subclass('TestError', { 45 | plugins: [TEST_PLUGIN], 46 | }) 47 | t.true( 48 | new TestError('message', { 49 | prop: { toSet: { prop: true } }, 50 | props: { prop: false }, 51 | }).prop, 52 | ) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/plugins/shape/duplicate.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses } from '../../helpers/main.test.js' 5 | 6 | const definePluginsSameClass = (ErrorClass, pluginA, pluginB) => 7 | ErrorClass.subclass.bind(undefined, 'TestError', { 8 | plugins: [pluginA, pluginB], 9 | }) 10 | 11 | const definePluginsSubClass = (ErrorClass, pluginA, pluginB) => { 12 | const TestError = ErrorClass.subclass('TestError', { plugins: [pluginA] }) 13 | return TestError.subclass.bind(undefined, 'SubTestError', { 14 | plugins: [pluginB], 15 | }) 16 | } 17 | 18 | each( 19 | ErrorClasses, 20 | [definePluginsSameClass, definePluginsSubClass], 21 | ({ title }, ErrorClass, definePlugins) => { 22 | test(`Cannot pass twice same plugins | ${title}`, (t) => { 23 | t.throws(definePlugins(ErrorClass, { name: 'one' }, { name: 'one' })) 24 | }) 25 | }, 26 | ) 27 | 28 | each( 29 | ErrorClasses, 30 | [definePluginsSameClass, definePluginsSubClass], 31 | ['staticMethods', 'instanceMethods'], 32 | ['staticMethods', 'instanceMethods'], 33 | // eslint-disable-next-line max-params 34 | ({ title }, ErrorClass, definePlugins, methodTypeA, methodTypeB) => { 35 | test(`plugin methods cannot be defined twice by different plugins | ${title}`, (t) => { 36 | t.throws( 37 | definePlugins( 38 | ErrorClass, 39 | { name: 'one', [methodTypeA]: { one: () => {} } }, 40 | { name: 'two', [methodTypeB]: { one: () => {} } }, 41 | ), 42 | ) 43 | }) 44 | }, 45 | ) 46 | 47 | each(ErrorClasses, ({ title }, ErrorClass) => { 48 | test(`plugin.staticMethods and instanceMethods cannot share the same names | ${title}`, (t) => { 49 | t.throws( 50 | ErrorClass.subclass.bind(undefined, 'TestError', { 51 | plugins: [ 52 | { 53 | name: 'one', 54 | staticMethods: { one: () => {} }, 55 | instanceMethods: { one: () => {} }, 56 | }, 57 | ], 58 | }), 59 | ) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /examples/plugin/main.ts: -------------------------------------------------------------------------------- 1 | import type { Info, Plugin } from 'modern-errors' 2 | 3 | // /** 4 | // * Options of `modern-errors-example` 5 | // */ 6 | // export interface Options { 7 | // /** 8 | // * Description of `exampleOption`. 9 | // * 10 | // * @default true 11 | // */ 12 | // readonly exampleOption?: boolean 13 | // } 14 | 15 | /** 16 | * `modern-errors-example` plugin. 17 | * 18 | * Description of the plugin. 19 | */ 20 | const plugin = { 21 | // Name used to configure the plugin 22 | name: 'example' as const, 23 | 24 | // // Set error properties 25 | // properties: (info: Info['properties']): { exampleProp: unknown } => 26 | // ({}) 27 | 28 | // // Add error instance methods like 29 | // // `ErrorClass.exampleMethod(error, ...args)` 30 | // instanceMethods: { 31 | // /** 32 | // * Description of `ErrorClass.exampleMethod(error)`. 33 | // * 34 | // * @example 35 | // * ```js 36 | // * const value = ErrorClass.exampleMethod(error, arg) 37 | // * ``` 38 | // */ 39 | // exampleMethod: ( 40 | // info: Info['instanceMethods'], 41 | // ...args: unknown[] 42 | // ): void => { 43 | // // ... 44 | // }, 45 | // }, 46 | 47 | // // Add `ErrorClass` static methods like `ErrorClass.staticMethod(...args)` 48 | // staticMethods: { 49 | // /** 50 | // * Description of `ErrorClass.staticMethod()`. 51 | // * 52 | // * @example 53 | // * ```js 54 | // * const value = ErrorClass.staticMethod(arg) 55 | // * ``` 56 | // */ 57 | // staticMethod: ( 58 | // info: Info['staticMethods'], 59 | // ...args: unknown[] 60 | // ): void => { 61 | // // ... 62 | // }, 63 | // }, 64 | 65 | // // Validate and normalize options 66 | // getOptions: (options: Options, full: boolean): Options => options, 67 | 68 | // // Determine if a value is plugin's options 69 | // isOptions: (options: unknown): boolean => typeof options === 'boolean', 70 | } satisfies Plugin 71 | 72 | export default plugin 73 | -------------------------------------------------------------------------------- /src/plugins/info/error.js: -------------------------------------------------------------------------------- 1 | import { mergePluginsOpts } from '../../options/merge.js' 2 | import { finalizePluginsOpts } from '../../options/plugins.js' 3 | import { classesData, instancesData } from '../../subclass/map.js' 4 | 5 | // Create `info.errorInfo(error)` which returns error-specific information: 6 | // `ErrorClass`, `ErrorClasses` and `options`. 7 | // This is meant to be used by plugins either: 8 | // - Operating on nested errors 9 | // - With static methods operating on errors 10 | // - E.g. when integrating with another library's plugin system, static 11 | // methods are needed to return that other plugin, but they need to take 12 | // errors as argument 13 | // If the `error` is not an `ErrorClass` instance, it is normalized to one. 14 | export const getAnyErrorInfo = ( 15 | { ErrorClass, methodOpts, plugins, plugin }, 16 | error, 17 | ) => { 18 | const errorA = ErrorClass.normalize(error) 19 | const info = getKnownErrorInfo({ error: errorA, methodOpts, plugins, plugin }) 20 | return { ...info, error: errorA } 21 | } 22 | 23 | // Retrieve `info` of a normalized error 24 | export const getKnownErrorInfo = ({ error, methodOpts, plugins, plugin }) => { 25 | const ErrorClass = error.constructor 26 | const ErrorClasses = getSubclasses(ErrorClass) 27 | const { classOpts } = classesData.get(ErrorClass) 28 | const { pluginsOpts } = instancesData.get(error) 29 | const pluginsOptsA = mergePluginsOpts(classOpts, pluginsOpts, plugins) 30 | const options = finalizePluginsOpts({ 31 | pluginsOpts: pluginsOptsA, 32 | methodOpts, 33 | plugins, 34 | plugin, 35 | }) 36 | return { ErrorClass, ErrorClasses, options } 37 | } 38 | 39 | // `ErrorClasses` are passed to all plugin methods. 40 | // It excludes parent classes. 41 | // A shallow copy is done to prevent mutations. 42 | // This is an array, not an object, since some error classes might have 43 | // duplicate names. 44 | export const getSubclasses = (ErrorClass) => { 45 | const { subclasses } = classesData.get(ErrorClass) 46 | return [ErrorClass, ...subclasses] 47 | } 48 | -------------------------------------------------------------------------------- /src/plugins/shape/methods.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses } from '../../helpers/main.test.js' 5 | import { TEST_PLUGIN } from '../../helpers/plugin.test.js' 6 | 7 | each( 8 | ErrorClasses, 9 | [true, { getProp: true }, { getProp: undefined }], 10 | ({ title }, ErrorClass, methods) => { 11 | test(`Should validate plugin.instanceMethods | ${title}`, (t) => { 12 | t.throws( 13 | ErrorClass.subclass.bind(undefined, 'TestError', { 14 | plugins: [{ ...TEST_PLUGIN, instanceMethods: methods }], 15 | }), 16 | ) 17 | }) 18 | 19 | test(`Should validate plugin.staticMethods | ${title}`, (t) => { 20 | t.throws( 21 | ErrorClass.subclass.bind(undefined, 'TestError', { 22 | plugins: [{ ...TEST_PLUGIN, staticMethods: methods }], 23 | }), 24 | ) 25 | }) 26 | }, 27 | ) 28 | 29 | each( 30 | ErrorClasses, 31 | ['staticMethods', 'instanceMethods'], 32 | [...Reflect.ownKeys(Error), 'normalize', 'subclass'], 33 | // eslint-disable-next-line max-params 34 | ({ title }, ErrorClass, methodType, propName) => { 35 | test(`plugin.instanceMethods|staticMethods cannot redefine native Error.* | ${title}`, (t) => { 36 | t.throws( 37 | ErrorClass.subclass.bind(undefined, 'TestError', { 38 | plugins: [{ ...TEST_PLUGIN, [methodType]: { [propName]: () => {} } }], 39 | }), 40 | ) 41 | }) 42 | }, 43 | ) 44 | 45 | each( 46 | ErrorClasses, 47 | [ 48 | ...new Set([ 49 | ...Reflect.ownKeys(Error.prototype), 50 | ...Reflect.ownKeys(Object.prototype), 51 | ]), 52 | ], 53 | ({ title }, ErrorClass, propName) => { 54 | test(`plugin.instanceMethods cannot redefine native Error.prototype.* | ${title}`, (t) => { 55 | t.throws( 56 | ErrorClass.subclass.bind(undefined, 'TestError', { 57 | plugins: [ 58 | { ...TEST_PLUGIN, instanceMethods: { [propName]: () => {} } }, 59 | ], 60 | }), 61 | ) 62 | }) 63 | }, 64 | ) 65 | -------------------------------------------------------------------------------- /src/plugins/instance/call.d.ts: -------------------------------------------------------------------------------- 1 | /* jscpd:ignore-start */ 2 | import type { MethodOptions } from '../../options/method.js' 3 | import type { UnionToIntersection } from '../../utils/intersect.js' 4 | import type { SliceFirst } from '../../utils/slice.js' 5 | import type { Plugin, Plugins } from '../shape/main.js' 6 | 7 | import type { InstanceMethod, InstanceMethods } from './main.js' 8 | /* jscpd:ignore-end */ 9 | 10 | /** 11 | * Bound instance method parameters 12 | */ 13 | type ErrorInstanceMethodParams< 14 | InstanceMethodArg extends InstanceMethod, 15 | MethodOptionsArg extends MethodOptions, 16 | > = 17 | | SliceFirst> 18 | | ([MethodOptionsArg] extends [never] 19 | ? never 20 | : readonly [ 21 | ...SliceFirst>, 22 | MethodOptionsArg, 23 | ]) 24 | 25 | /** 26 | * Bound instance method of a plugin 27 | */ 28 | type ErrorInstanceMethod< 29 | InstanceMethodArg extends InstanceMethod, 30 | MethodOptionsArg extends MethodOptions, 31 | > = ( 32 | ...args: ErrorInstanceMethodParams 33 | ) => ReturnType 34 | 35 | /** 36 | * Bound instance methods of a plugin, always defined 37 | */ 38 | type ErrorInstanceMethods< 39 | InstanceMethodsArg extends InstanceMethods, 40 | MethodOptionsArg extends MethodOptions, 41 | > = { 42 | readonly [MethodName in keyof InstanceMethodsArg]: ErrorInstanceMethod< 43 | InstanceMethodsArg[MethodName], 44 | MethodOptionsArg 45 | > 46 | } 47 | 48 | /** 49 | * Bound instance methods of a plugin, if defined 50 | */ 51 | type PluginInstanceMethods = PluginArg extends { 52 | instanceMethods: InstanceMethods 53 | } 54 | ? ErrorInstanceMethods> 55 | : object 56 | 57 | /** 58 | * Bound instance methods of all plugins 59 | */ 60 | export type PluginsInstanceMethods = 61 | UnionToIntersection> & {} 62 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import errorCustomClass from 'error-custom-class' 2 | 3 | import { setAggregateErrors } from './merge/aggregate.js' 4 | import { mergeCause } from './merge/cause.js' 5 | import { computePluginsOpts } from './options/instance.js' 6 | import { CORE_PLUGINS } from './plugins/core/main.js' 7 | import { setPluginsProperties } from './plugins/properties/main.js' 8 | import { createClass } from './subclass/create.js' 9 | import { classesData, instancesData } from './subclass/map.js' 10 | import { validateSubclass } from './subclass/validate.js' 11 | 12 | const BaseModernError = errorCustomClass('BaseModernError') 13 | 14 | // Base class for all error classes. 15 | // We do not call `Error.captureStackTrace(this, CustomErrorClass)` because: 16 | // - It is V8 specific 17 | // - And on V8 (unlike in some browsers like Firefox), `Error.stack` 18 | // automatically omits the stack lines from custom error constructors 19 | // - Also, this would force child classes to also use 20 | // `Error.captureStackTrace()` 21 | /* c8 ignore start */ 22 | /* eslint-disable fp/no-this, no-constructor-return */ 23 | class CustomModernError extends BaseModernError { 24 | constructor(message, opts) { 25 | const ErrorClass = new.target 26 | validateSubclass(ErrorClass) 27 | const { plugins } = classesData.get(ErrorClass) 28 | const { nativeOpts, errors, pluginsOpts } = computePluginsOpts( 29 | ErrorClass, 30 | plugins, 31 | opts, 32 | ) 33 | 34 | super(message, nativeOpts) 35 | 36 | setAggregateErrors(this, errors) 37 | const error = mergeCause(this, ErrorClass) 38 | instancesData.set(error, { pluginsOpts }) 39 | setPluginsProperties(error, plugins) 40 | 41 | return error 42 | } 43 | } 44 | /* eslint-enable fp/no-this, no-constructor-return */ 45 | /* c8 ignore stop */ 46 | 47 | const ModernError = createClass({ 48 | ParentError: Error, 49 | ErrorClass: CustomModernError, 50 | parentOpts: {}, 51 | classOpts: {}, 52 | parentPlugins: [], 53 | plugins: CORE_PLUGINS, 54 | className: 'ModernError', 55 | }) 56 | export default ModernError 57 | -------------------------------------------------------------------------------- /src/main.test.js: -------------------------------------------------------------------------------- 1 | import { basename } from 'node:path' 2 | 3 | import test from 'ava' 4 | import { each } from 'test-each' 5 | 6 | import { 7 | ErrorClasses, 8 | ErrorSubclasses, 9 | ModernError, 10 | } from './helpers/main.test.js' 11 | import { getUnknownErrors } from './helpers/unknown.test.js' 12 | 13 | const { propertyIsEnumerable: isEnum } = Object.prototype 14 | 15 | const isStackLine = (line) => line.trim().startsWith('at ') 16 | 17 | each(ErrorClasses, ({ title }, ErrorClass) => { 18 | const message = 'test' 19 | const error = new ErrorClass(message) 20 | 21 | test(`instanceof can be used with known errors | ${title}`, (t) => { 22 | t.true(error instanceof Error) 23 | t.true(error instanceof ErrorClass) 24 | t.true(error instanceof ModernError) 25 | }) 26 | 27 | test(`error.constructor is correct | ${title}`, (t) => { 28 | t.is(error.constructor, ErrorClass) 29 | }) 30 | 31 | test(`error.message is correct | ${title}`, (t) => { 32 | t.is(error.message, message) 33 | t.false(isEnum.call(error, 'message')) 34 | }) 35 | 36 | test(`error.stack is correct | ${title}`, (t) => { 37 | t.true(error.stack.includes(message)) 38 | t.false(isEnum.call(error, 'stack')) 39 | }) 40 | 41 | test(`error.stack does not include the constructor | ${title}`, (t) => { 42 | const lines = error.stack.split('\n') 43 | const stackIndex = lines.findIndex(isStackLine) 44 | t.true(lines[stackIndex].includes(basename(import.meta.url))) 45 | }) 46 | 47 | test(`error.toString() is correct | ${title}`, (t) => { 48 | t.is(error.toString(), `${ErrorClass.name}: ${message}`) 49 | }) 50 | }) 51 | 52 | each(ErrorSubclasses, ({ title }, ErrorClass) => { 53 | test(`instanceof prevents naming collisions | ${title}`, (t) => { 54 | const OtherError = ModernError.subclass(ErrorClass.name) 55 | t.false(new OtherError('test') instanceof ErrorClass) 56 | }) 57 | }) 58 | 59 | each(getUnknownErrors(), ({ title }, getUnknownError) => { 60 | test(`instanceof can be used with known errors | ${title}`, (t) => { 61 | t.false(getUnknownError() instanceof ModernError) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/plugins/shape/duplicate.js: -------------------------------------------------------------------------------- 1 | // Ensure each plugin does not define the same method as both instanceMethod 2 | // and staticMethod 3 | export const validateSameMethods = ({ 4 | instanceMethods, 5 | staticMethods, 6 | fullName, 7 | }) => { 8 | const duplicateName = findDuplicateKey(staticMethods, instanceMethods) 9 | 10 | if (duplicateName !== undefined) { 11 | throw new TypeError( 12 | `The plugin "${fullName}" must not define "${duplicateName}()" as both "instanceMethods" and "staticMethods".`, 13 | ) 14 | } 15 | } 16 | 17 | // Ensure the same plugin is not passed twice. 18 | // Also ensure two plugins do not define the same instanceMethods|staticMethods 19 | export const validateDuplicatePlugins = (plugins, ParentError) => { 20 | plugins.forEach((pluginA, indexA) => { 21 | validateDuplicatePlugin({ pluginA, indexA, plugins, ParentError }) 22 | }) 23 | } 24 | 25 | const validateDuplicatePlugin = ({ pluginA, indexA, plugins, ParentError }) => { 26 | plugins.forEach((pluginB, indexB) => { 27 | validateEachPlugin({ pluginA, pluginB, indexA, indexB, ParentError }) 28 | }) 29 | } 30 | 31 | const validateEachPlugin = ({ 32 | pluginA, 33 | pluginB, 34 | indexA, 35 | indexB, 36 | ParentError, 37 | }) => { 38 | if (indexA === indexB) { 39 | return 40 | } 41 | 42 | if (pluginA.name === pluginB.name) { 43 | throw new TypeError( 44 | `The "plugins" option of "${ParentError.name}.subclass()" must not include "${pluginA.fullName}": this plugin has already been included.`, 45 | ) 46 | } 47 | 48 | const duplicateName = findDuplicateKey( 49 | { ...pluginA.instanceMethods, ...pluginA.staticMethods }, 50 | { ...pluginB.instanceMethods, ...pluginB.staticMethods }, 51 | ) 52 | 53 | if (duplicateName !== undefined) { 54 | throw new TypeError( 55 | `The plugins "${pluginA.fullName}" and "${pluginB.fullName}" must not both define the same "${duplicateName}()" method.`, 56 | ) 57 | } 58 | } 59 | 60 | const findDuplicateKey = (objectA, objectB) => { 61 | const keysA = Object.keys(objectA) 62 | return Object.keys(objectB).find((key) => keysA.includes(key)) 63 | } 64 | -------------------------------------------------------------------------------- /src/plugins/instance/main.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorSubclasses } from '../../helpers/plugin.test.js' 5 | 6 | each(ErrorSubclasses, ({ title }, ErrorClass) => { 7 | test(`plugin.instanceMethods are set on known errors | ${title}`, (t) => { 8 | t.is(typeof new ErrorClass('message').getInstance, 'function') 9 | }) 10 | 11 | test(`Mixed plugin.instanceMethods are set on ErrorClass | ${title}`, (t) => { 12 | t.is(typeof ErrorClass.getInstance, 'function') 13 | }) 14 | 15 | test(`plugin.instanceMethods are inherited | ${title}`, (t) => { 16 | t.false(Object.hasOwn(new ErrorClass('message'), 'getInstance')) 17 | }) 18 | 19 | test(`plugin.instanceMethods are not enumerable | ${title}`, (t) => { 20 | t.false( 21 | Object.getOwnPropertyDescriptor(ErrorClass.prototype, 'getInstance') 22 | .enumerable, 23 | ) 24 | }) 25 | 26 | test(`Mixed plugin.instanceMethods are not enumerable | ${title}`, (t) => { 27 | t.deepEqual(Object.keys(ErrorClass), []) 28 | }) 29 | 30 | test(`plugin.instanceMethods validate the context | ${title}`, (t) => { 31 | const error = new ErrorClass('message') 32 | t.notThrows(error.getInstance.bind(error)) 33 | t.throws(error.getInstance) 34 | }) 35 | 36 | test(`Mixed plugin.instanceMethods context is bound | ${title}`, (t) => { 37 | const { getInstance } = ErrorClass 38 | const error = new ErrorClass('message') 39 | t.deepEqual(getInstance(error, 0).args, [0]) 40 | }) 41 | 42 | test(`plugin.instanceMethods are passed the error | ${title}`, (t) => { 43 | const error = new ErrorClass('message') 44 | t.is(error.getInstance().error, error) 45 | }) 46 | 47 | test(`Mixed plugin.instanceMethods are passed the error | ${title}`, (t) => { 48 | const error = new ErrorClass('message') 49 | t.deepEqual(ErrorClass.getInstance(error, 0).error, error) 50 | }) 51 | 52 | test(`Mixed plugin.instanceMethods are passed the normalized error | ${title}`, (t) => { 53 | const { error } = ErrorClass.getInstance() 54 | t.true(error instanceof ErrorClass) 55 | t.is(error.message, '') 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /src/merge/prefix.js: -------------------------------------------------------------------------------- 1 | import setErrorMessage from 'set-error-message' 2 | 3 | import { isSubclass } from '../utils/subclass.js' 4 | 5 | // When switching error classes, we keep the old class name in the error 6 | // message, except: 7 | // - When the error name is absent or is too generic 8 | // - Including `Error`, `TypeError`, etc. except when `error.name` has been 9 | // set to something else, since this is a common pattern 10 | // - When the child is a subclass of the parent, since the class does not 11 | // change then 12 | // - When the parent is a subclass of the child, since the new class becomes 13 | // the subclass, which already contains the other class in its chain, i.e. 14 | // not worth adding to the message 15 | // - When the error classes are different but have the same name. 16 | // This is common when a library wraps another one with identical classes. 17 | // For example, a CLI wrapping a programmatic library. 18 | export const shouldPrefixCause = (error, ErrorClass) => { 19 | const { cause } = error 20 | return ( 21 | hasValidName(cause) && 22 | (hasUsefulName(cause, ErrorClass) || cause.name !== cause.constructor.name) 23 | ) 24 | } 25 | 26 | const hasValidName = (cause) => 27 | typeof cause.name === 'string' && 28 | cause.name !== '' && 29 | typeof cause.constructor === 'function' && 30 | typeof cause.constructor.name === 'string' 31 | 32 | const hasUsefulName = (cause, ErrorClass) => 33 | !( 34 | isSubclass(cause.constructor, ErrorClass) || 35 | isSubclass(ErrorClass, cause.constructor) || 36 | cause.constructor.name in globalThis || 37 | cause.name === ErrorClass.name 38 | ) 39 | 40 | // Prefix `cause.name` to its `message` 41 | export const prefixCause = (cause) => { 42 | const oldMessage = typeof cause.message === 'string' ? cause.message : '' 43 | const newMessage = 44 | oldMessage === '' ? cause.name : `${cause.name}: ${oldMessage}` 45 | setErrorMessage(cause, newMessage) 46 | return oldMessage 47 | } 48 | 49 | // Undo prefixing once it's been used by `merge-error-cause` 50 | export const undoPrefixCause = (cause, oldMessage) => { 51 | setErrorMessage(cause, oldMessage) 52 | } 53 | -------------------------------------------------------------------------------- /src/subclass/check.js: -------------------------------------------------------------------------------- 1 | import { isSubclass } from '../utils/subclass.js' 2 | 3 | // Confirm `custom` option is valid 4 | export const checkCustom = (custom, ParentError) => { 5 | if (typeof custom !== 'function') { 6 | throw new TypeError( 7 | `The "custom" class of "${ParentError.name}.subclass()" must be a class: ${custom}`, 8 | ) 9 | } 10 | 11 | checkParent(custom, ParentError) 12 | checkPrototype(custom, ParentError) 13 | } 14 | 15 | // We do not allow passing `ParentError` without extending from it, since 16 | // `undefined` can be used for it instead. 17 | // We do not allow extending from `ParentError` indirectly: 18 | // - This promotes using subclassing through `ErrorClass.subclass()`, since it 19 | // reduces the risk of user instantiating unregistered class 20 | // - This promotes `ErrorClass.subclass()` as a pattern for subclassing, to 21 | // reduce the risk of directly extending a registered class without 22 | // registering the subclass 23 | const checkParent = (custom, ParentError) => { 24 | if (custom === ParentError) { 25 | throw new TypeError( 26 | `The "custom" class of "${ParentError.name}.subclass()" must extend from ${ParentError.name}, but not be ${ParentError.name} itself.`, 27 | ) 28 | } 29 | 30 | if (!isSubclass(custom, ParentError)) { 31 | throw new TypeError( 32 | `The "custom" class of "${ParentError.name}.subclass()" must extend from ${ParentError.name}.`, 33 | ) 34 | } 35 | 36 | if (Object.getPrototypeOf(custom) !== ParentError) { 37 | throw new TypeError( 38 | `The "custom" class of "${ParentError.name}.subclass()" must extend directly from ${ParentError.name}.`, 39 | ) 40 | } 41 | } 42 | 43 | const checkPrototype = (custom, ParentError) => { 44 | if (typeof custom.prototype !== 'object' || custom.prototype === null) { 45 | throw new TypeError( 46 | `The "custom" class's prototype of "${ParentError.name}.subclass()" is invalid: ${custom.prototype}`, 47 | ) 48 | } 49 | 50 | if (custom.prototype.constructor !== custom) { 51 | throw new TypeError( 52 | `The "custom" class of "${ParentError.name}.subclass()" has an invalid "constructor" property.`, 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a bug 3 | title: Please replace with a clear and descriptive title 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for reporting this bug! 9 | - type: checkboxes 10 | attributes: 11 | label: Guidelines 12 | options: 13 | - label: 14 | Please search other issues to make sure this bug has not already 15 | been reported. 16 | required: true 17 | - label: 18 | If this is related to a typo or the documentation being unclear, 19 | please click on the relevant page's `Edit` button (pencil icon) and 20 | suggest a correction instead. 21 | required: true 22 | - type: textarea 23 | attributes: 24 | label: Describe the bug 25 | placeholder: A clear and concise description of what the bug is. 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Steps to reproduce 31 | placeholder: | 32 | Step-by-step instructions on how to reproduce the behavior. 33 | Example: 34 | 1. Type the following command: [...] 35 | 2. etc. 36 | validations: 37 | required: true 38 | - type: textarea 39 | attributes: 40 | label: Configuration 41 | placeholder: Command line options and/or configuration file, if any. 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: Environment 47 | description: | 48 | Enter the following command in a terminal and copy/paste its output: 49 | ```bash 50 | npx envinfo --system --binaries --browsers --npmPackages modern-errors 51 | ``` 52 | validations: 53 | required: true 54 | - type: checkboxes 55 | attributes: 56 | label: Pull request (optional) 57 | description: 58 | Pull requests are welcome! If you would like to help us fix this bug, 59 | please check our [contributions 60 | guidelines](../blob/main/CONTRIBUTING.md). 61 | options: 62 | - label: I can submit a pull request. 63 | required: false 64 | -------------------------------------------------------------------------------- /src/merge/aggregate.test-d.ts: -------------------------------------------------------------------------------- 1 | import ModernError from 'modern-errors' 2 | import { expectType } from 'tsd' 3 | 4 | const CustomError = ModernError.subclass('CustomError', { 5 | custom: class extends ModernError { 6 | prop = true 7 | }, 8 | }) 9 | type CustomInstance = (typeof CustomError)['prototype'] 10 | 11 | const unknownErrorsArray = [true] as readonly true[] 12 | const unknownErrors = [true] as readonly [true] 13 | const knownErrors = [new CustomError('')] as const 14 | 15 | expectType({} as CustomInstance['errors']) 16 | 17 | expectType(new ModernError('', { errors: unknownErrorsArray }).errors) 18 | expectType(new CustomError('', { errors: unknownErrorsArray }).errors) 19 | expectType<[Error]>(new ModernError('', { errors: unknownErrors }).errors) 20 | expectType<[Error]>(new CustomError('', { errors: unknownErrors }).errors) 21 | expectType<[CustomInstance]>( 22 | new ModernError('', { errors: knownErrors }).errors, 23 | ) 24 | expectType<[CustomInstance]>( 25 | new CustomError('', { errors: knownErrors }).errors, 26 | ) 27 | expectType( 28 | new ModernError('', { 29 | cause: new CustomError('', { errors: unknownErrorsArray }), 30 | }).errors, 31 | ) 32 | expectType( 33 | new CustomError('', { 34 | cause: new CustomError('', { errors: unknownErrorsArray }), 35 | }).errors, 36 | ) 37 | expectType<[Error]>( 38 | new ModernError('', { cause: new CustomError('', { errors: unknownErrors }) }) 39 | .errors, 40 | ) 41 | expectType<[Error]>( 42 | new CustomError('', { cause: new CustomError('', { errors: unknownErrors }) }) 43 | .errors, 44 | ) 45 | expectType<[Error, CustomInstance]>( 46 | new ModernError('', { 47 | cause: new CustomError('', { errors: unknownErrors }), 48 | errors: knownErrors, 49 | }).errors, 50 | ) 51 | expectType<[Error, CustomInstance]>( 52 | new CustomError('', { 53 | cause: new CustomError('', { errors: unknownErrors }), 54 | errors: knownErrors, 55 | }).errors, 56 | ) 57 | 58 | expectType(new ModernError('').errors) 59 | expectType(new CustomError('').errors) 60 | // @ts-expect-error 61 | new ModernError('', { errors: true }) 62 | // @ts-expect-error 63 | new CustomError('', { errors: true }) 64 | -------------------------------------------------------------------------------- /src/plugins/info/main.test-d.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorClass, Info } from 'modern-errors' 2 | import { expectAssignable, expectType } from 'tsd' 3 | 4 | const instanceMethodsInfo = {} as Info['instanceMethods'] 5 | // @ts-expect-error 6 | instanceMethodsInfo.other 7 | expectAssignable(instanceMethodsInfo.error) 8 | expectAssignable(instanceMethodsInfo.options) 9 | expectAssignable(instanceMethodsInfo.ErrorClass) 10 | expectAssignable(instanceMethodsInfo.ErrorClasses) 11 | expectType(({} as Info['instanceMethods']).options) 12 | 13 | const staticMethodsInfo = {} as Info['staticMethods'] 14 | // @ts-expect-error 15 | staticMethodsInfo.other 16 | // @ts-expect-error 17 | staticMethodsInfo.error 18 | expectType(staticMethodsInfo.options) 19 | expectType(staticMethodsInfo.ErrorClass) 20 | expectType( 21 | staticMethodsInfo.ErrorClasses, 22 | ) 23 | expectType(staticMethodsInfo.errorInfo) 24 | expectType(({} as Info['staticMethods']).options) 25 | 26 | const propertiesInfo = {} as Info['properties'] 27 | // @ts-expect-error 28 | propertiesInfo.other 29 | expectType(propertiesInfo.error) 30 | expectType(propertiesInfo.options) 31 | expectType(propertiesInfo.ErrorClass) 32 | expectType(propertiesInfo.ErrorClasses) 33 | expectType(propertiesInfo.errorInfo) 34 | expectType(({} as Info['properties']).options) 35 | 36 | const errorInfo = instanceMethodsInfo.errorInfo('') 37 | instanceMethodsInfo.errorInfo('') 38 | expectType(errorInfo) 39 | // @ts-expect-error 40 | errorInfo.other 41 | expectType(errorInfo.error) 42 | expectType(errorInfo.options) 43 | expectType(errorInfo.ErrorClass) 44 | expectType(errorInfo.ErrorClasses) 45 | // @ts-expect-error 46 | errorInfo.errorInfo 47 | expectType(({} as Info['errorInfo']).options) 48 | -------------------------------------------------------------------------------- /src/plugins/core/all.test-d.ts: -------------------------------------------------------------------------------- 1 | import ModernError, { type ErrorInstance } from 'modern-errors' 2 | import beautifulPlugin from 'modern-errors-beautiful' 3 | import bugsPlugin from 'modern-errors-bugs' 4 | import cleanPlugin from 'modern-errors-clean' 5 | import cliPlugin from 'modern-errors-cli' 6 | import httpPlugin, { type HttpResponse } from 'modern-errors-http' 7 | import processPlugin from 'modern-errors-process' 8 | import serializePlugin, { type ErrorObject } from 'modern-errors-serialize' 9 | import switchPlugin from 'modern-errors-switch' 10 | import winstonPlugin, { type Format } from 'modern-errors-winston' 11 | import { expectAssignable, expectType } from 'tsd' 12 | 13 | const plugins = [ 14 | beautifulPlugin, 15 | bugsPlugin, 16 | cleanPlugin, 17 | cliPlugin, 18 | httpPlugin, 19 | processPlugin, 20 | serializePlugin, 21 | switchPlugin, 22 | winstonPlugin, 23 | ] 24 | const BaseError = ModernError.subclass('BaseError', { plugins }) 25 | const error = new BaseError('') 26 | 27 | ModernError.subclass('TestError', { 28 | plugins, 29 | beautiful: { icon: 'warning' }, 30 | bugs: 'https://example.com', 31 | cli: { silent: true }, 32 | http: { type: '' }, 33 | process: { exit: true }, 34 | winston: { stack: true }, 35 | }) 36 | 37 | expectAssignable>(error) 38 | expectAssignable(error) 39 | expectAssignable(error) 40 | 41 | expectType(error.message) 42 | expectType(error.stack) 43 | 44 | expectType(BaseError.httpResponse(error, { type: '' })) 45 | expectType(error.httpResponse({ type: '' })) 46 | expectType(BaseError.exit(error, { silent: true })) 47 | expectType(error.exit({ silent: true })) 48 | expectType(BaseError.beautiful(error, { stack: true })) 49 | expectType(error.beautiful({ stack: true })) 50 | const errorObject = BaseError.serialize(error) 51 | expectType(errorObject) 52 | expectType(error.toJSON()) 53 | 54 | const restore = BaseError.logProcess({ exit: true }) 55 | expectType(restore()) 56 | expectType(BaseError.parse(errorObject)) 57 | expectType( 58 | BaseError.switch('').case([TypeError, SyntaxError], BaseError).default(), 59 | ) 60 | expectType(BaseError.fullFormat({ stack: true })) 61 | -------------------------------------------------------------------------------- /src/plugins/shape/main.js: -------------------------------------------------------------------------------- 1 | import isPlainObj from 'is-plain-obj' 2 | 3 | import { normalizeGetOptions } from '../../options/get.js' 4 | import { normalizeIsOptions } from '../../options/method.js' 5 | 6 | import { validateDuplicatePlugins, validateSameMethods } from './duplicate.js' 7 | import { normalizeAllMethods } from './methods.js' 8 | import { validatePluginName } from './name.js' 9 | 10 | // Validate and normalize plugins. 11 | // Also merge plugins of parent and child classes. 12 | export const normalizePlugins = (parentPlugins, plugins, ParentError) => { 13 | const pluginsA = normalizePluginsOpt(ParentError, plugins) 14 | const pluginsB = pluginsA.map((plugin) => 15 | normalizePlugin(plugin, ParentError), 16 | ) 17 | const pluginsC = [...parentPlugins, ...pluginsB] 18 | validateDuplicatePlugins(pluginsC, ParentError) 19 | return pluginsC 20 | } 21 | 22 | const normalizePluginsOpt = (ParentError, plugins = []) => { 23 | if (!Array.isArray(plugins)) { 24 | throw new TypeError( 25 | `The "plugins" option of "${ParentError.name}.subclass()" must be an array: ${plugins}`, 26 | ) 27 | } 28 | 29 | return plugins 30 | } 31 | 32 | const normalizePlugin = (plugin, ParentError) => { 33 | if (!isPlainObj(plugin)) { 34 | throw new TypeError( 35 | `The "plugins" option of "${ParentError.name}.subclass()" must be an array of plugin objects: ${plugin}`, 36 | ) 37 | } 38 | 39 | const pluginA = validatePluginName(plugin) 40 | validateOptionalFuncs(pluginA) 41 | const pluginB = normalizeAllMethods(pluginA) 42 | validateSameMethods(pluginB) 43 | const pluginC = normalizeIsOptions({ plugin: pluginB }) 44 | const pluginD = normalizeGetOptions({ plugin: pluginC }) 45 | return pluginD 46 | } 47 | 48 | const validateOptionalFuncs = (plugin) => { 49 | OPTIONAL_FUNCS.forEach((funcName) => { 50 | validateOptionalFunc(plugin, funcName) 51 | }) 52 | } 53 | 54 | const OPTIONAL_FUNCS = ['isOptions', 'getOptions', 'properties'] 55 | 56 | const validateOptionalFunc = (plugin, funcName) => { 57 | const funcValue = plugin[funcName] 58 | 59 | if (funcValue !== undefined && typeof funcValue !== 'function') { 60 | throw new TypeError( 61 | `The plugin "${plugin.fullName}"'s "${funcName}()" property must be either undefined or a function, not: ${funcValue}`, 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/plugins/shape/methods.js: -------------------------------------------------------------------------------- 1 | import isPlainObj from 'is-plain-obj' 2 | 3 | // Validate and normalize `plugin.instanceMethods|staticMethods` 4 | export const normalizeAllMethods = (plugin) => { 5 | const pluginA = normalizeMethods({ 6 | ...INSTANCE_METHODS, 7 | plugin, 8 | propName: 'instanceMethods', 9 | }) 10 | const pluginB = normalizeMethods({ 11 | ...STATIC_METHODS, 12 | plugin: pluginA, 13 | propName: 'instanceMethods', 14 | }) 15 | const pluginC = normalizeMethods({ 16 | ...STATIC_METHODS, 17 | plugin: pluginB, 18 | propName: 'staticMethods', 19 | }) 20 | return pluginC 21 | } 22 | 23 | const INSTANCE_METHODS = { 24 | coreObject: Error.prototype, 25 | coreObjectName: 'error', 26 | forbiddenNames: new Set([]), 27 | } 28 | 29 | const STATIC_METHODS = { 30 | coreObject: Error, 31 | coreObjectName: 'Error', 32 | forbiddenNames: new Set(['normalize', 'subclass']), 33 | } 34 | 35 | const normalizeMethods = ({ 36 | plugin, 37 | propName, 38 | coreObject, 39 | coreObjectName, 40 | forbiddenNames, 41 | }) => { 42 | const methods = plugin[propName] ?? {} 43 | 44 | if (!isPlainObj(methods)) { 45 | throw new TypeError( 46 | `The plugin "${plugin.fullName}"'s "${propName}" property must be either undefined or a plain object, not: ${methods}`, 47 | ) 48 | } 49 | 50 | Object.entries(methods).forEach(([methodName, methodValue]) => { 51 | validateMethod({ 52 | methodValue, 53 | propName, 54 | methodName, 55 | plugin, 56 | coreObject, 57 | coreObjectName, 58 | forbiddenNames, 59 | }) 60 | }) 61 | return { ...plugin, [propName]: methods } 62 | } 63 | 64 | const validateMethod = ({ 65 | methodValue, 66 | propName, 67 | methodName, 68 | plugin, 69 | coreObject, 70 | coreObjectName, 71 | forbiddenNames, 72 | }) => { 73 | if (typeof methodValue !== 'function') { 74 | throw new TypeError( 75 | `The plugin "${plugin.fullName}"'s "${propName}.${methodName}" property must be a function, not: ${methodValue}`, 76 | ) 77 | } 78 | 79 | if (forbiddenNames.has(methodName) || methodName in coreObject) { 80 | throw new TypeError( 81 | `The plugin "${plugin.fullName}"'s "${propName}.${methodName}" property name is invalid: "${coreObjectName}.${methodName}" already exists.`, 82 | ) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/merge/aggregate.d.ts: -------------------------------------------------------------------------------- 1 | import type { Cause } from '../options/instance.js' 2 | 3 | /** 4 | * Single aggregate error 5 | */ 6 | export type AggregateErrorOption = unknown 7 | 8 | /** 9 | * Aggregate `errors` array 10 | */ 11 | export type DefinedAggregateErrors = readonly AggregateErrorOption[] 12 | 13 | /** 14 | * Optional aggregate `errors` array 15 | */ 16 | export type AggregateErrors = DefinedAggregateErrors | undefined 17 | 18 | /** 19 | * Normalize each error in the `errors` option to `Error` instances 20 | */ 21 | type NormalizeAggregateError = 22 | ErrorArg extends Error ? ErrorArg : Error 23 | 24 | /** 25 | * Normalize all errors in the `errors` option to `Error` instances 26 | */ 27 | type NormalizeAggregateErrors< 28 | AggregateErrorsArg extends DefinedAggregateErrors, 29 | > = AggregateErrorsArg extends never[] 30 | ? [] 31 | : AggregateErrorsArg extends readonly [ 32 | infer AggregateErrorArg extends AggregateErrorOption, 33 | ...infer Rest extends DefinedAggregateErrors, 34 | ] 35 | ? [ 36 | NormalizeAggregateError, 37 | ...NormalizeAggregateErrors, 38 | ] 39 | : NormalizeAggregateError[] 40 | 41 | /** 42 | * Concatenate the `errors` option with `cause.errors`, if either is defined 43 | */ 44 | type ConcatAggregateErrors< 45 | AggregateErrorsArg extends AggregateErrors, 46 | CauseArg extends Cause, 47 | > = [AggregateErrorsArg] extends [DefinedAggregateErrors] 48 | ? 'errors' extends keyof CauseArg 49 | ? CauseArg['errors'] extends DefinedAggregateErrors 50 | ? [...CauseArg['errors'], ...AggregateErrorsArg] 51 | : AggregateErrorsArg 52 | : AggregateErrorsArg 53 | : 'errors' extends keyof CauseArg 54 | ? CauseArg['errors'] extends DefinedAggregateErrors 55 | ? CauseArg['errors'] 56 | : never 57 | : never 58 | 59 | /** 60 | * Object with an `errors` property to set as `error.errors` 61 | */ 62 | export type AggregateErrorsProperty< 63 | AggregateErrorsArg extends AggregateErrors, 64 | CauseArg extends Cause, 65 | > = 66 | ConcatAggregateErrors extends never 67 | ? { errors?: Error[] } 68 | : { 69 | errors: NormalizeAggregateErrors< 70 | ConcatAggregateErrors 71 | > 72 | } 73 | -------------------------------------------------------------------------------- /src/options/class.d.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorProps } from '../plugins/core/props/main.js' 2 | import type { Plugins } from '../plugins/shape/main.js' 3 | import type { CustomClass } from '../subclass/custom.js' 4 | 5 | import type { PluginsOptions } from './plugins.js' 6 | 7 | /** 8 | * Class-specific options, excluding plugin options 9 | */ 10 | interface KnownClassOptions< 11 | ChildPlugins extends Plugins, 12 | ChildCustomClass extends CustomClass, 13 | > { 14 | /** 15 | * Plugins to add. 16 | * 17 | * @example 18 | * ```js 19 | * import modernErrorsBugs from 'modern-errors-bugs' 20 | * import modernErrorsSerialize from 'modern-errors-serialize' 21 | * 22 | * export const BaseError = ModernError.subclass('BaseError', { 23 | * plugins: [modernErrorsBugs, modernErrorsSerialize], 24 | * }) 25 | * ``` 26 | */ 27 | readonly plugins?: ChildPlugins 28 | 29 | /** 30 | * Custom class to add any methods, `constructor` or properties. 31 | * 32 | * @example 33 | * ```js 34 | * export const InputError = BaseError.subclass('InputError', { 35 | * // The `class` must extend from the parent error class 36 | * custom: class extends BaseError { 37 | * // If a `constructor` is defined, its parameters must be (message, options) 38 | * constructor(message, options) { 39 | * message += message.endsWith('.') ? '' : '.' 40 | * super(message, options) 41 | * } 42 | * 43 | * isUserInput() { 44 | * // ... 45 | * } 46 | * }, 47 | * }) 48 | * 49 | * const error = new InputError('Wrong user name') 50 | * console.log(error.message) // 'Wrong user name.' 51 | * console.log(error.isUserInput()) 52 | * ``` 53 | */ 54 | readonly custom?: ChildCustomClass 55 | } 56 | 57 | /** 58 | * Class-specific options, used internally only with additional generics 59 | */ 60 | export type SpecificClassOptions< 61 | PluginsArg extends Plugins, 62 | ChildPlugins extends Plugins, 63 | ChildProps extends ErrorProps, 64 | ChildCustomClass extends CustomClass, 65 | > = KnownClassOptions & 66 | PluginsOptions<[...PluginsArg, ...ChildPlugins], ChildProps> 67 | 68 | /** 69 | * Class-specific options passed to `ErrorClass.subclass('ErrorName', options)` 70 | */ 71 | export type ClassOptions = 72 | SpecificClassOptions 73 | -------------------------------------------------------------------------------- /src/options/instance.js: -------------------------------------------------------------------------------- 1 | import { excludeKeys, includeKeys } from 'filter-obj' 2 | import isPlainObj from 'is-plain-obj' 3 | 4 | import { instancesData } from '../subclass/map.js' 5 | 6 | import { deepClone } from './clone.js' 7 | import { getPluginNames, mergePluginsOpts } from './merge.js' 8 | 9 | // Split `nativeOpts` (native `Error` options like `cause`) and `pluginsOpts` 10 | export const computePluginsOpts = (ErrorClass, plugins, opts = {}) => { 11 | validateOpts(ErrorClass, opts) 12 | const { errors, ...optsA } = opts 13 | const nativeOpts = excludeKeys(optsA, getPluginNames(plugins)) 14 | const pluginsOpts = includeKeys(optsA, getPluginNames(plugins)) 15 | const pluginsOptsA = wrapPluginsOpts(plugins, pluginsOpts, nativeOpts) 16 | return { nativeOpts, errors, pluginsOpts: pluginsOptsA } 17 | } 18 | 19 | // Unknown `Error` options are not validated, for compatibility with any 20 | // potential JavaScript platform, since `error` has many non-standard elements. 21 | // - This also ensures compatibility with future JavaScript features or with 22 | // any `Error` polyfill 23 | // We allow `undefined` message since it is allowed by the standard, internally 24 | // normalized to an empty string 25 | // - However, we do not allow it to be optional, i.e. options are always the 26 | // second object, and empty strings must be used to ignore messages, since 27 | // this is: 28 | // - More standard 29 | // - More monomorphic 30 | // - Safer against injections 31 | const validateOpts = (ErrorClass, opts) => { 32 | if (!isPlainObj(opts)) { 33 | throw new TypeError( 34 | `Error options must be a plain object or undefined: ${opts}`, 35 | ) 36 | } 37 | 38 | if (opts.custom !== undefined) { 39 | throw new TypeError( 40 | `Error option "custom" must be passed to "${ErrorClass.name}.subclass()", not to error constructors.`, 41 | ) 42 | } 43 | } 44 | 45 | // We keep track of un-normalized plugins options to re-use them later inside 46 | // `error.*()` instance methods. 47 | // We merge the options before they are normalized, since this is how users who 48 | // pass those options understand them. 49 | const wrapPluginsOpts = (plugins, pluginsOpts, { cause }) => { 50 | if (!instancesData.has(cause)) { 51 | return pluginsOpts 52 | } 53 | 54 | const causePluginsOpts = instancesData.get(cause).pluginsOpts 55 | const pluginsOptsA = mergePluginsOpts(causePluginsOpts, pluginsOpts, plugins) 56 | return deepClone(pluginsOptsA) 57 | } 58 | -------------------------------------------------------------------------------- /src/options/class.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses, ErrorSubclasses } from '../helpers/plugin.test.js' 5 | 6 | each(ErrorClasses, ({ title }, ErrorClass) => { 7 | test(`Validate invalid class options | ${title}`, (t) => { 8 | t.throws(ErrorClass.subclass.bind(undefined, 'TestError', true)) 9 | }) 10 | }) 11 | 12 | each(ErrorSubclasses, ({ title }, ErrorClass) => { 13 | test(`Can pass class options | ${title}`, (t) => { 14 | const TestError = ErrorClass.subclass('TestError', { prop: true }) 15 | t.true(new TestError('test').properties.options.prop) 16 | }) 17 | 18 | test(`Class options are readonly | ${title}`, (t) => { 19 | const prop = { one: true } 20 | const TestError = ErrorClass.subclass('TestError', { prop }) 21 | // eslint-disable-next-line fp/no-mutation 22 | prop.one = false 23 | t.true(new TestError('test').properties.options.prop.one) 24 | }) 25 | 26 | test(`Cannot pass unknown options | ${title}`, (t) => { 27 | t.throws(ErrorClass.subclass.bind(undefined, 'TestError', { one: true })) 28 | }) 29 | 30 | test(`Child class options have priority over parent ones | ${title}`, (t) => { 31 | const TestError = ErrorClass.subclass('TestError', { prop: false }) 32 | const SubTestError = TestError.subclass('SubTestError', { prop: true }) 33 | t.true(new SubTestError('test').properties.options.prop) 34 | }) 35 | 36 | test(`Undefined child class options are ignored | ${title}`, (t) => { 37 | const TestError = ErrorClass.subclass('TestError', { prop: true }) 38 | const SubTestError = TestError.subclass('SubTestError', { prop: undefined }) 39 | t.true(new SubTestError('test').properties.options.prop) 40 | }) 41 | 42 | test(`Object child options are shallowly merged to parent options | ${title}`, (t) => { 43 | const TestError = ErrorClass.subclass('TestError', { 44 | prop: { five: false, one: false, two: { three: false } }, 45 | }) 46 | const SubTestError = TestError.subclass('SubTestError', { 47 | prop: { one: true, two: { three: true }, four: true }, 48 | }) 49 | t.deepEqual(new SubTestError('test').properties.options.prop, { 50 | one: true, 51 | two: { three: true }, 52 | four: true, 53 | five: false, 54 | }) 55 | }) 56 | 57 | test(`plugin.getOptions() full is false for class options | ${title}`, (t) => { 58 | t.throws( 59 | ErrorClass.subclass.bind(undefined, 'TestError', { prop: 'partial' }), 60 | ) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/plugins/static/main.js: -------------------------------------------------------------------------------- 1 | import { setNonEnumProp } from '../../utils/descriptors.js' 2 | 3 | import { callStaticMethod } from './call.js' 4 | 5 | // Plugins can define a `staticMethods` object, which is merged to 6 | // `ErrorClass.*`. 7 | // We privilege `instanceMethods` when one of the arguments is `error` 8 | // - We do not pass `error` to static methods to encourage this 9 | // State in plugins: 10 | // - `modern-errors` does not have mutable state 11 | // - This allows declaring error classes in the top-level state, instead of 12 | // passing them around as variables 13 | // - Plugins can keep error-specific state using a WeakMap in the top-level 14 | // scope 15 | // - For other state: 16 | // - Such as network connection, class instance, etc. 17 | // - If this is fast enough, the state can be created and deleted inside a 18 | // single plugin method 19 | // - Otherwise: 20 | // - Users should create the state object, pass it to plugin methods, and 21 | // potentially destroy it 22 | // - Plugins can provide with methods to simplify creating those state 23 | // objects 24 | // - But those must be returned as local variables, not stored as 25 | // global state 26 | // - reasons: 27 | // - This keeps the API simple, shifting consumer-specific or 28 | // tool-specific logic to consumers 29 | // - This is concurrent-safe 30 | // - This ensures instance methods are used instead of passing errors 31 | // to static methods 32 | // - For consistency 33 | // - To ensure method options can be passed 34 | // We do not provide with `plugin.init()`: 35 | // - This would encourage stateful plugins 36 | // - Instead, static methods that initialize should be used 37 | export const addAllStaticMethods = (plugins, ErrorClass) => { 38 | plugins.forEach((plugin) => { 39 | addStaticMethods(plugin, plugins, ErrorClass) 40 | }) 41 | } 42 | 43 | const addStaticMethods = (plugin, plugins, ErrorClass) => { 44 | Object.entries(plugin.staticMethods).forEach( 45 | addStaticMethod.bind(undefined, { plugin, plugins, ErrorClass }), 46 | ) 47 | } 48 | 49 | const addStaticMethod = ( 50 | { plugin, plugins, ErrorClass }, 51 | [methodName, methodFunc], 52 | ) => { 53 | setNonEnumProp( 54 | ErrorClass, 55 | methodName, 56 | callStaticMethod.bind(undefined, { 57 | methodFunc, 58 | plugin, 59 | plugins, 60 | ErrorClass, 61 | }), 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/subclass/custom.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses } from '../helpers/main.test.js' 5 | 6 | each(ErrorClasses, ({ title }, ErrorClass) => { 7 | test(`"custom" option defaults to parent class | ${title}`, (t) => { 8 | t.is(Object.getPrototypeOf(ErrorClass.subclass('TestError')), ErrorClass) 9 | }) 10 | 11 | test(`"custom" class is inherited | ${title}`, (t) => { 12 | const TestError = ErrorClass.subclass('TestError', { 13 | custom: class extends ErrorClass { 14 | prop = true 15 | static staticProp = true 16 | }, 17 | }) 18 | t.true(TestError.staticProp) 19 | t.true(new TestError('test').prop) 20 | }) 21 | 22 | test(`instanceof works with "custom" classes | ${title}`, (t) => { 23 | const CustomError = ErrorClass.subclass('CustomError', { 24 | custom: class extends ErrorClass {}, 25 | }) 26 | t.true(new CustomError('test') instanceof ErrorClass) 27 | }) 28 | 29 | test(`Parent class is "custom" class when passed | ${title}`, (t) => { 30 | const custom = class extends ErrorClass {} 31 | const CustomError = ErrorClass.subclass('CustomError', { custom }) 32 | t.is(Object.getPrototypeOf(CustomError), custom) 33 | }) 34 | 35 | test(`"custom" option is not modified | ${title}`, (t) => { 36 | class ReadonlyClass extends ErrorClass {} 37 | const { name } = ReadonlyClass 38 | ErrorClass.subclass('CustomError', { custom: ReadonlyClass }) 39 | const { name: newName } = ReadonlyClass 40 | t.is(newName, name) 41 | }) 42 | 43 | test(`"custom" option can be shared | ${title}`, (t) => { 44 | class SharedError extends ErrorClass { 45 | static prop = true 46 | } 47 | t.true(ErrorClass.subclass('OneError', { custom: SharedError }).prop) 48 | t.true(ErrorClass.subclass('TwoError', { custom: SharedError }).prop) 49 | }) 50 | }) 51 | 52 | each( 53 | ErrorClasses, 54 | ['message', 'properties', 'getInstance'], 55 | ({ title }, ErrorClass, propName) => { 56 | test(`"custom" option can override other properties | ${title}`, (t) => { 57 | const TestError = ErrorClass.subclass('TestError', { 58 | custom: class extends ErrorClass { 59 | constructor(message, options) { 60 | super(message, options) 61 | // eslint-disable-next-line fp/no-this, fp/no-mutation 62 | this[propName] = true 63 | } 64 | }, 65 | }) 66 | t.true(new TestError('test')[propName]) 67 | }) 68 | }, 69 | ) 70 | -------------------------------------------------------------------------------- /src/options/plugins.js: -------------------------------------------------------------------------------- 1 | import { deepClone } from './clone.js' 2 | import { getPluginOpts } from './get.js' 3 | import { mergeMethodOpts } from './method.js' 4 | 5 | // Retrieve, validate and normalize all options for a given plugin. 6 | // Those are passed to `plugin.properties|instanceMethods.*`. 7 | // We pass whether the `options` object is partial or not using `full`: 8 | // - This allows validation|normalization that requires options to be full, 9 | // such as: 10 | // - Required properties 11 | // - Properties depending on others 12 | // - While still encouraging validation to be performed as early as possible 13 | // - As opposed to splitting `getOptions()` into two different methods, 14 | // since that might encourage using only the method with the full 15 | // `options` object, which would prevent any early validation 16 | // We pass positional arguments to `plugin.isOptions()` and `getOptions()` as 17 | // opposed to the object passed to other methods since: 18 | // - Those two methods are simpler and more functional 19 | // - This makes it clear that `options` is post-getOptions 20 | // `getOptions()` is meant for error instance-agnostic logic: 21 | // - This is because it is called early when classes are being defined 22 | // - `info.*` is not available 23 | // - Error-specific logic should be inside other plugin methods instead 24 | // Any validation|normalization specific to a method should be done inside that 25 | // method, as opposed to inside `plugin.getOptions()`. 26 | // Plugins should avoid: 27 | // - Letting options be optionally a function: class constructors can be used 28 | // for this, by manipulating `options` and passing it to `super()` 29 | // - Using non-JSON serializable options, unless unavoidable 30 | // Plugin options correspond to their `name`: 31 | // - It should match the package name 32 | // - This convention is simple to understand 33 | // - This promotes a single option name per plugin, which reduces the potential 34 | // for name conflict 35 | // - This reduces cross-plugin dependencies since they cannot easily reference 36 | // each other, keeping them decoupled from each other 37 | export const finalizePluginsOpts = ({ 38 | pluginsOpts, 39 | methodOpts, 40 | plugins, 41 | plugin, 42 | }) => { 43 | const pluginsOptsA = mergeMethodOpts(pluginsOpts, methodOpts, plugins) 44 | const pluginsOptsB = deepClone(pluginsOptsA) 45 | const options = getPluginOpts({ 46 | pluginsOpts: pluginsOptsB, 47 | plugin, 48 | full: true, 49 | }) 50 | return options 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "modern-errors", 3 | "version": "7.1.4", 4 | "type": "module", 5 | "exports": { 6 | "types": "./build/src/main.d.ts", 7 | "default": "./build/src/main.js" 8 | }, 9 | "main": "./build/src/main.js", 10 | "types": "./build/src/main.d.ts", 11 | "files": [ 12 | "build/src/**/*.{js,json,d.ts}", 13 | "!build/src/**/*.test.js", 14 | "!build/src/{helpers,fixtures}" 15 | ], 16 | "sideEffects": false, 17 | "scripts": { 18 | "test": "gulp test" 19 | }, 20 | "description": "Handle errors in a simple, stable, consistent way", 21 | "keywords": [ 22 | "nodejs", 23 | "javascript", 24 | "stacktrace", 25 | "library", 26 | "typescript", 27 | "browser", 28 | "errors", 29 | "monitoring", 30 | "error-monitoring", 31 | "message", 32 | "error-handler", 33 | "error-handling", 34 | "error", 35 | "code-quality", 36 | "cause", 37 | "error-reporting", 38 | "error-classes", 39 | "exceptions", 40 | "plugins", 41 | "framework" 42 | ], 43 | "license": "MIT", 44 | "homepage": "https://www.github.com/ehmicky/modern-errors", 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/ehmicky/modern-errors.git" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/ehmicky/modern-errors/issues" 51 | }, 52 | "author": "ehmicky (https://github.com/ehmicky)", 53 | "directories": { 54 | "example": "examples", 55 | "lib": "src" 56 | }, 57 | "dependencies": { 58 | "error-class-utils": "^4.0.1", 59 | "error-custom-class": "^10.0.1", 60 | "filter-obj": "^6.1.0", 61 | "is-error-instance": "^3.0.1", 62 | "is-plain-obj": "^4.1.0", 63 | "merge-error-cause": "^5.0.2", 64 | "normalize-exception": "^4.0.1", 65 | "set-error-message": "^3.0.1", 66 | "set-error-props": "^6.0.1", 67 | "set-error-stack": "^3.0.1" 68 | }, 69 | "devDependencies": { 70 | "@ehmicky/dev-tasks": "^3.0.34", 71 | "@ehmicky/eslint-config": "^20.0.32", 72 | "@ehmicky/prettier-config": "^1.0.6", 73 | "modern-errors-beautiful": "^2.0.1", 74 | "modern-errors-bugs": "^5.0.1", 75 | "modern-errors-clean": "^6.0.1", 76 | "modern-errors-cli": "^6.0.0", 77 | "modern-errors-http": "^5.0.1", 78 | "modern-errors-process": "^5.0.1", 79 | "modern-errors-serialize": "^6.1.1", 80 | "modern-errors-switch": "^4.1.0", 81 | "modern-errors-winston": "^5.0.2", 82 | "test-each": "^7.0.1" 83 | }, 84 | "engines": { 85 | "node": ">=18.18.0" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/subclass/check.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses } from '../helpers/main.test.js' 5 | 6 | class NullClass {} 7 | 8 | // eslint-disable-next-line fp/no-mutating-methods 9 | Object.setPrototypeOf(NullClass, null) 10 | 11 | each( 12 | ErrorClasses, 13 | [ 14 | null, 15 | 'TestError', 16 | NullClass, 17 | Object, 18 | Function, 19 | () => {}, 20 | Error, 21 | TypeError, 22 | class ChildTypeError extends TypeError {}, 23 | class NoParentError {}, 24 | class InvalidError extends Object {}, 25 | ], 26 | ({ title }, ErrorClass, custom) => { 27 | test(`Validate against invalid "custom" option | ${title}`, (t) => { 28 | t.throws(ErrorClass.subclass.bind(undefined, 'TestError', { custom })) 29 | }) 30 | }, 31 | ) 32 | 33 | each(ErrorClasses, ({ title }, ErrorClass) => { 34 | test(`Parent error cannot be passed as is | ${title}`, (t) => { 35 | t.throws( 36 | ErrorClass.subclass.bind(undefined, 'SelfError', { custom: ErrorClass }), 37 | ) 38 | }) 39 | 40 | test(`Subclasses must not extend from their parent indirectly | ${title}`, (t) => { 41 | const IndirectError = ErrorClass.subclass('IndirectError') 42 | t.throws( 43 | ErrorClass.subclass.bind(undefined, 'SubError', { 44 | custom: class extends IndirectError {}, 45 | }), 46 | ) 47 | }) 48 | 49 | test(`Subclasses must not extend from siblings | ${title}`, (t) => { 50 | const TestError = ErrorClass.subclass('TestError') 51 | const SiblingError = ErrorClass.subclass('SiblingError') 52 | t.throws( 53 | TestError.subclass.bind(undefined, 'SubError', { 54 | custom: class extends SiblingError {}, 55 | }), 56 | ) 57 | }) 58 | 59 | test(`Validate against invalid constructor | ${title}`, (t) => { 60 | class custom extends ErrorClass {} 61 | // eslint-disable-next-line fp/no-mutation 62 | custom.prototype.constructor = Error 63 | t.throws(ErrorClass.subclass.bind(undefined, 'TestError', { custom })) 64 | }) 65 | }) 66 | 67 | each(ErrorClasses, ['', null], ({ title }, ErrorClass, invalidPrototype) => { 68 | test(`Validate against invalid prototypes | ${title}`, (t) => { 69 | // eslint-disable-next-line unicorn/consistent-function-scoping 70 | const custom = () => {} 71 | // eslint-disable-next-line fp/no-mutation 72 | custom.prototype = invalidPrototype 73 | // eslint-disable-next-line fp/no-mutating-methods 74 | Object.setPrototypeOf(custom, ErrorClass) 75 | t.throws(ErrorClass.subclass.bind(undefined, 'TestError', { custom })) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /src/plugins/shape/main.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses } from '../../helpers/main.test.js' 5 | import { TEST_PLUGIN } from '../../helpers/plugin.test.js' 6 | 7 | each( 8 | ErrorClasses, 9 | ['isOptions', 'getOptions', 'properties'], 10 | ({ title }, ErrorClass, propName) => { 11 | test(`Should validate functions | ${title}`, (t) => { 12 | t.throws( 13 | ErrorClass.subclass.bind(undefined, 'TestError', { 14 | plugins: [{ ...TEST_PLUGIN, [propName]: true }], 15 | }), 16 | ) 17 | }) 18 | }, 19 | ) 20 | 21 | each(ErrorClasses, ({ title }, ErrorClass) => { 22 | test(`Should allow valid plugins | ${title}`, (t) => { 23 | t.notThrows( 24 | ErrorClass.subclass.bind(undefined, 'TestError', { 25 | plugins: [TEST_PLUGIN], 26 | }), 27 | ) 28 | }) 29 | 30 | test(`Should allow passing no plugins | ${title}`, (t) => { 31 | t.notThrows( 32 | ErrorClass.subclass.bind(undefined, 'TestError', { plugins: [] }), 33 | ) 34 | }) 35 | 36 | test(`Should validate plugins is an array | ${title}`, (t) => { 37 | t.throws( 38 | ErrorClass.subclass.bind(undefined, 'TestError', { plugins: true }), 39 | ) 40 | }) 41 | 42 | test(`Should validate plugin is an object | ${title}`, (t) => { 43 | t.throws( 44 | ErrorClass.subclass.bind(undefined, 'TestError', { plugins: [true] }), 45 | ) 46 | }) 47 | 48 | test(`Should inherit plugins | ${title}`, (t) => { 49 | const TestError = ErrorClass.subclass('TestError', { 50 | plugins: [{ name: 'one', properties: () => ({ one: true }) }], 51 | }) 52 | const testError = new TestError('test') 53 | t.true(testError.one) 54 | t.false('two' in testError) 55 | 56 | const SubTestError = TestError.subclass('SubTestError', { 57 | plugins: [{ name: 'two', properties: () => ({ two: true }) }], 58 | }) 59 | const subTestError = new SubTestError('test') 60 | t.true(subTestError.one) 61 | t.true(subTestError.two) 62 | }) 63 | }) 64 | 65 | each( 66 | ErrorClasses, 67 | [ 68 | { isOptions: undefined }, 69 | { getOptions: undefined }, 70 | { properties: undefined }, 71 | { instanceMethods: undefined }, 72 | { staticMethods: undefined }, 73 | { instanceMethods: {} }, 74 | { staticMethods: {} }, 75 | ], 76 | ({ title }, ErrorClass, opts) => { 77 | test(`Should allow optional properties | ${title}`, (t) => { 78 | t.notThrows( 79 | ErrorClass.subclass.bind(undefined, 'TestError', { 80 | plugins: [{ ...TEST_PLUGIN, ...opts }], 81 | }), 82 | ) 83 | }) 84 | }, 85 | ) 86 | -------------------------------------------------------------------------------- /src/options/method.js: -------------------------------------------------------------------------------- 1 | import { mergePluginsOpts } from './merge.js' 2 | 3 | // We return `true` by default to enforce defining `plugin.isOptions()` to be 4 | // able to use any `staticMethods|instanceMethods` with arguments. 5 | // However, if there are no options (i.e. `plugin.getOptions()` is undefined), 6 | // we return `false` since `isOptions()` is unnecessary then. 7 | export const normalizeIsOptions = ({ 8 | plugin, 9 | plugin: { getOptions, isOptions = () => getOptions !== undefined }, 10 | }) => ({ ...plugin, isOptions }) 11 | 12 | // Options can be passed as the last argument of `staticMethods|instanceMethods` 13 | // `plugin.isOptions(lastArgument) => boolean` can be defined to distinguish 14 | // that last options argument from other arguments for any of those methods. 15 | // `plugin.isOptions()` should be as wide as possible: 16 | // - It should only distinguish `options` from methods actual last arguments 17 | // - While still ensuring invalid options are passed to `plugin.getOptions()` 18 | // instead of considering them not options 19 | // `plugin.isOptions()` has the following pros: 20 | // - Users can pass `options[pluginName]` instead of `options` 21 | // - Plugin methods can have variadic and optional parameters 22 | // - It does not rely on brittle `Function.length` 23 | // Instance method options have priority over error instance options: 24 | // - The instance method's caller is usually unaware of instance options, 25 | // making it surprising if some of the options passed to the method are not 26 | // taken into account due to being overridden 27 | // - Instance methods are more specific since they can be called multiple times 28 | // per error instance with different options 29 | // Static method options also have priority over error instance options, for 30 | // consistency with instance methods. 31 | export const getMethodOpts = (args, plugin) => { 32 | if (args.length === 0) { 33 | return { args } 34 | } 35 | 36 | const lastArg = args.at(-1) 37 | return lastArgIsOptions(plugin, lastArg) 38 | ? { args: args.slice(0, -1), methodOpts: { [plugin.name]: lastArg } } 39 | : { args } 40 | } 41 | 42 | const lastArgIsOptions = ({ isOptions, fullName }, lastArg) => { 43 | const isOptionsResult = isOptions(lastArg) 44 | 45 | if (typeof isOptionsResult !== 'boolean') { 46 | throw new TypeError( 47 | `The plugin "${fullName}"'s "isOptions()" method must return a boolean, not: ${typeof isOptionsResult}`, 48 | ) 49 | } 50 | 51 | return isOptionsResult 52 | } 53 | 54 | export const mergeMethodOpts = (pluginsOpts, methodOpts, plugins) => 55 | methodOpts === undefined 56 | ? pluginsOpts 57 | : mergePluginsOpts(pluginsOpts, methodOpts, plugins) 58 | -------------------------------------------------------------------------------- /src/subclass/create.js: -------------------------------------------------------------------------------- 1 | import { setErrorName } from 'error-class-utils' 2 | 3 | import { getClassOpts, normalizeClassOpts } from '../options/class.js' 4 | import { addAllInstanceMethods } from '../plugins/instance/main.js' 5 | import { normalizePlugins } from '../plugins/shape/main.js' 6 | import { addAllStaticMethods } from '../plugins/static/main.js' 7 | import { setNonEnumProp } from '../utils/descriptors.js' 8 | 9 | import { getErrorClass } from './custom.js' 10 | import { classesData } from './map.js' 11 | import { normalize } from './normalize.js' 12 | 13 | // Create a new error class. 14 | // We allow `ErrorClass.subclass()` to create subclasses. This can be used to: 15 | // - Share options and custom logic between error classes 16 | // - Bind and override options and custom logic between modules 17 | // - Only export parent classes to consumers 18 | // We do not validate duplicate class names since sub-groups of classes might 19 | // be used separately, explaining those duplicate names. 20 | export const createSubclass = (ParentError, className, classOpts) => { 21 | const { classOpts: parentOpts, plugins: parentPlugins } = 22 | classesData.get(ParentError) 23 | const { custom, plugins, ...classOptsA } = normalizeClassOpts( 24 | ParentError, 25 | classOpts, 26 | ) 27 | const ErrorClass = getErrorClass(ParentError, custom) 28 | addParentSubclass(ErrorClass, ParentError) 29 | return createClass({ 30 | ParentError, 31 | ErrorClass, 32 | parentOpts, 33 | classOpts: classOptsA, 34 | parentPlugins, 35 | plugins, 36 | className, 37 | }) 38 | } 39 | 40 | // Keep track of error subclasses, to use as `info.ErrorClasses` in plugins 41 | const addParentSubclass = (ErrorClass, ParentError) => { 42 | const { subclasses, ...classProps } = classesData.get(ParentError) 43 | classesData.set(ParentError, { 44 | ...classProps, 45 | subclasses: [...subclasses, ErrorClass], 46 | }) 47 | } 48 | 49 | // Unlike `createSubclass()`, this is run by the top-level `ModernError` as well 50 | export const createClass = ({ 51 | ParentError, 52 | ErrorClass, 53 | parentOpts, 54 | classOpts, 55 | parentPlugins, 56 | plugins, 57 | className, 58 | }) => { 59 | const pluginsA = normalizePlugins(parentPlugins, plugins, ParentError) 60 | const classOptsA = getClassOpts(parentOpts, classOpts, pluginsA) 61 | classesData.set(ErrorClass, { 62 | classOpts: classOptsA, 63 | plugins: pluginsA, 64 | subclasses: [], 65 | }) 66 | setErrorName(ErrorClass, className) 67 | setClassMethods(ErrorClass, pluginsA) 68 | return ErrorClass 69 | } 70 | 71 | const setClassMethods = (ErrorClass, plugins) => { 72 | setNonEnumProp(ErrorClass, 'normalize', normalize.bind(undefined, ErrorClass)) 73 | setNonEnumProp( 74 | ErrorClass, 75 | 'subclass', 76 | createSubclass.bind(undefined, ErrorClass), 77 | ) 78 | addAllInstanceMethods(plugins, ErrorClass) 79 | addAllStaticMethods(plugins, ErrorClass) 80 | } 81 | -------------------------------------------------------------------------------- /src/options/method.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { 5 | callInstanceMethod, 6 | callMixMethod, 7 | callStaticMethod, 8 | } from '../helpers/info.test.js' 9 | import { ErrorSubclasses } from '../helpers/main.test.js' 10 | import { 11 | ErrorSubclasses as PluginErrorClasses, 12 | TEST_PLUGIN, 13 | } from '../helpers/plugin.test.js' 14 | 15 | each( 16 | PluginErrorClasses, 17 | [callStaticMethod, callInstanceMethod, callMixMethod], 18 | ({ title }, ErrorClass, callMethod) => { 19 | test(`plugin methods can pass method options | ${title}`, (t) => { 20 | const TestError = ErrorClass.subclass('TestError', { prop: false }) 21 | t.true(callMethod(TestError, true).options.prop) 22 | }) 23 | 24 | test(`plugin methods merge method options to class options shallowly | ${title}`, (t) => { 25 | const TestError = ErrorClass.subclass('TestError', { 26 | prop: { one: false, two: { three: false }, five: false }, 27 | }) 28 | t.deepEqual( 29 | callMethod(TestError, { one: true, two: { three: true }, four: true }) 30 | .options.prop, 31 | { one: true, two: { three: true }, four: true, five: false }, 32 | ) 33 | }) 34 | 35 | test(`plugin methods pass last argument as method option if plugin.isOptions() returns true | ${title}`, (t) => { 36 | t.deepEqual(callMethod(ErrorClass, 0, true).args, [0]) 37 | }) 38 | 39 | test(`plugin methods pass last argument as method option if plugin.isOptions() returns false | ${title}`, (t) => { 40 | t.deepEqual(callMethod(ErrorClass, 0, 1).args, [0, 1]) 41 | }) 42 | 43 | test(`plugin methods can have no arguments | ${title}`, (t) => { 44 | t.deepEqual(callMethod(ErrorClass).args, []) 45 | }) 46 | }, 47 | ) 48 | 49 | each( 50 | ErrorSubclasses, 51 | [callStaticMethod, callInstanceMethod, callMixMethod], 52 | ({ title }, ErrorClass, callMethod) => { 53 | test(`plugin methods pass last argument as method options if plugin.isOptions() is undefined | ${title}`, (t) => { 54 | const TestError = ErrorClass.subclass('TestError', { 55 | plugins: [{ ...TEST_PLUGIN, isOptions: undefined }], 56 | }) 57 | t.deepEqual(callMethod(TestError, 0, true).args, [0]) 58 | }) 59 | 60 | test(`plugin methods do not pass last argument as method options if plugin.isOptions() and getOptions() are both undefined | ${title}`, (t) => { 61 | const TestError = ErrorClass.subclass('TestError', { 62 | plugins: [ 63 | { ...TEST_PLUGIN, isOptions: undefined, getOptions: undefined }, 64 | ], 65 | }) 66 | t.deepEqual(callMethod(TestError, 0, true).args, [0, true]) 67 | }) 68 | 69 | test(`plugin methods throw if plugin.isOptions() does not return a boolean | ${title}`, (t) => { 70 | const TestError = ErrorClass.subclass('TestError', { 71 | plugins: [{ ...TEST_PLUGIN, isOptions: () => {} }], 72 | }) 73 | t.throws(callMethod.bind(undefined, TestError, 0)) 74 | }) 75 | }, 76 | ) 77 | -------------------------------------------------------------------------------- /src/subclass/normalize.d.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AggregateErrorOption, 3 | DefinedAggregateErrors, 4 | } from '../merge/aggregate.js' 5 | import type { ErrorInstance } from '../merge/cause.js' 6 | import type { ErrorProps } from '../plugins/core/props/main.js' 7 | import type { Plugins } from '../plugins/shape/main.js' 8 | import type { SetProps } from '../utils/omit.js' 9 | 10 | import type { SpecificErrorClass } from './create.js' 11 | import type { CustomClass } from './custom.js' 12 | 13 | /** 14 | * `ErrorClass.normalize()`. 15 | * 16 | * @private This type is private and only exported as a temporary workaround 17 | * for an open issue with TypeScript. It will be removed in a future release. 18 | * See: 19 | * 20 | * - [modern-errors issue #18](https://github.com/ehmicky/modern-errors/issues/18) 21 | * - [TypeScript issue #47663](https://github.com/microsoft/TypeScript/issues/47663) 22 | */ 23 | export type NormalizeError< 24 | PluginsArg extends Plugins, 25 | ErrorPropsArg extends ErrorProps, 26 | CustomClassArg extends CustomClass, 27 | > = < 28 | ErrorArg, 29 | NewErrorClass extends SpecificErrorClass< 30 | PluginsArg, 31 | ErrorPropsArg, 32 | CustomClassArg 33 | > = SpecificErrorClass, 34 | >( 35 | error: ErrorArg, 36 | NewErrorClass?: NewErrorClass, 37 | ) => NormalizeDeepError< 38 | ErrorArg, 39 | InstanceType> & 40 | ErrorInstance, 41 | InstanceType & ErrorInstance 42 | > 43 | 44 | /** 45 | * Apply `ErrorClass.normalize()` on both `error` and `error.errors` 46 | */ 47 | type NormalizeDeepError< 48 | ErrorArg, 49 | ParentError extends ErrorInstance, 50 | NewError extends ErrorInstance, 51 | > = ErrorArg extends { 52 | errors: infer AggregateErrorsArg extends DefinedAggregateErrors 53 | } 54 | ? Omit, 'errors'> & { 55 | errors: NormalizeManyErrors 56 | } 57 | : NormalizeOneError 58 | 59 | /** 60 | * Apply `ErrorClass.normalize()` on `error.errors` 61 | */ 62 | type NormalizeManyErrors< 63 | AggregateErrorsArg extends DefinedAggregateErrors, 64 | ParentError extends ErrorInstance, 65 | NewError extends ErrorInstance, 66 | > = AggregateErrorsArg extends never[] 67 | ? [] 68 | : AggregateErrorsArg extends readonly [ 69 | infer AggregateErrorArg extends AggregateErrorOption, 70 | ...infer Rest extends DefinedAggregateErrors, 71 | ] 72 | ? [ 73 | NormalizeDeepError, 74 | ...NormalizeManyErrors, 75 | ] 76 | : NormalizeDeepError[] 77 | 78 | /** 79 | * Apply `ErrorClass.normalize()` on `error`, but not `error.errors` 80 | */ 81 | type NormalizeOneError< 82 | ErrorArg, 83 | ParentError extends ErrorInstance, 84 | NewError extends ErrorInstance, 85 | > = ErrorArg extends ParentError 86 | ? ErrorArg 87 | : ErrorArg extends Error 88 | ? SetProps 89 | : NewError 90 | -------------------------------------------------------------------------------- /src/merge/cause.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses } from '../helpers/main.test.js' 5 | import { 6 | getUnknownErrorInstances, 7 | getUnknownErrors, 8 | } from '../helpers/unknown.test.js' 9 | 10 | const assertInstanceOf = (t, error, ErrorClass) => { 11 | t.true(error instanceof ErrorClass) 12 | t.is(Object.getPrototypeOf(error), ErrorClass.prototype) 13 | t.is(error.name, ErrorClass.name) 14 | } 15 | 16 | each(ErrorClasses, getUnknownErrors(), ({ title }, ErrorClass, getError) => { 17 | test(`Unknown cause uses parent class and instance | ${title}`, (t) => { 18 | const cause = getError() 19 | const error = new ErrorClass('message', { cause }) 20 | assertInstanceOf(t, error, ErrorClass) 21 | t.not(error, cause) 22 | }) 23 | }) 24 | 25 | each( 26 | ErrorClasses, 27 | getUnknownErrorInstances(), 28 | ({ title }, ErrorClass, getError) => { 29 | test(`Unknown causes are merged | ${title}`, (t) => { 30 | const error = getError() 31 | error.one = true 32 | t.true(new ErrorClass('message', { cause: error }).one) 33 | }) 34 | }, 35 | ) 36 | 37 | each(ErrorClasses, ({ title }, ErrorClass) => { 38 | test(`ErrorClass with cause of subclass use child class and instance | ${title}`, (t) => { 39 | const TestError = ErrorClass.subclass('TestError') 40 | const cause = new TestError('causeMessage') 41 | const error = new ErrorClass('message', { cause }) 42 | assertInstanceOf(t, error, TestError) 43 | t.is(error, cause) 44 | }) 45 | 46 | test(`ErrorClass with cause of same class use child class and instance | ${title}`, (t) => { 47 | const cause = new ErrorClass('causeMessage') 48 | const error = new ErrorClass('message', { cause }) 49 | assertInstanceOf(t, error, ErrorClass) 50 | t.is(error, cause) 51 | }) 52 | 53 | test(`ErrorClass with cause of superclass use parent class and instance | ${title}`, (t) => { 54 | const TestError = ErrorClass.subclass('TestError') 55 | const cause = new ErrorClass('causeMessage') 56 | const error = new TestError('message', { cause }) 57 | assertInstanceOf(t, error, TestError) 58 | t.not(error, cause) 59 | }) 60 | 61 | test(`"cause" property is not left | ${title}`, (t) => { 62 | t.false('cause' in new ErrorClass('message', { cause: '' })) 63 | }) 64 | 65 | test(`"cause" message is merged | ${title}`, (t) => { 66 | const outerMessage = 'message' 67 | const innerMessage = 'causeMessage' 68 | const error = new ErrorClass(outerMessage, { cause: innerMessage }) 69 | t.false('cause' in error) 70 | t.is(error.message, `${innerMessage}\n${outerMessage}`) 71 | }) 72 | 73 | test(`"cause" properties are merged | ${title}`, (t) => { 74 | const cause = new ErrorClass('causeMessage', { props: { one: true } }) 75 | const error = new ErrorClass('message', { cause, props: { two: true } }) 76 | t.true(error.one) 77 | t.true(error.two) 78 | }) 79 | 80 | test(`"cause" is ignored if undefined | ${title}`, (t) => { 81 | const outerMessage = 'message' 82 | t.is( 83 | new ErrorClass(outerMessage, { cause: undefined }).message, 84 | outerMessage, 85 | ) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /src/subclass/normalize.js: -------------------------------------------------------------------------------- 1 | import normalizeException from 'normalize-exception' 2 | 3 | import { setNonEnumProp } from '../utils/descriptors.js' 4 | import { isSubclass } from '../utils/subclass.js' 5 | 6 | // `ErrorClass.normalize()` has two purposes: 7 | // - Normalizing exceptions: 8 | // - Inside any `catch` block 9 | // - To: 10 | // - Error instances with normal properties 11 | // - Instances of `ModernError` (or any subclass), so plugin instance 12 | // methods can be called 13 | // - This is meant to be called as `BaseError.normalize(error)` 14 | // - Assigning a default class: 15 | // - Inside a top-level `catch` block 16 | // - For errors that are generic, either: 17 | // - Not `BaseError` instances 18 | // - `BaseError` itself 19 | // - This is meant to be called as 20 | // `BaseError.normalize(error, UnknownError)` 21 | // `BaseError` is meant as a placeholder class 22 | // - It should be replaced by a subclass in a parent `catch` block 23 | // - This is useful when wrapping or normalizing errors 24 | // - By opposition, `BaseError.normalize(error, UnknownError)` should be 25 | // top-level, to ensure no parent `catch` block changes the class to a more 26 | // precise one, since this would keep `UnknownError` class options 27 | // This returns the `error` instead of throwing it so the user can handle it 28 | // before re-throwing it if needed. 29 | // This is called `normalize()`, not `normalizeError()` so it does not end 30 | // like the error classes. 31 | export const normalize = (ErrorClass, error, UnknownError = ErrorClass) => { 32 | if (!isSubclass(UnknownError, ErrorClass)) { 33 | throw new TypeError( 34 | `${ErrorClass.name}.normalize()'s second argument should be a subclass of ${ErrorClass.name}, not: ${UnknownError}`, 35 | ) 36 | } 37 | 38 | return normalizeError({ error, ErrorClass, UnknownError, parents: [] }) 39 | } 40 | 41 | const normalizeError = ({ error, ErrorClass, UnknownError, parents }) => { 42 | normalizeAggregateErrors({ error, ErrorClass, UnknownError, parents }) 43 | return shouldKeepClass(error, ErrorClass, UnknownError) 44 | ? normalizeException(error, { shallow: true }) 45 | : new UnknownError('', { cause: error }) 46 | } 47 | 48 | // `error.errors` are normalized before `error` so that if some are missing a 49 | // stack trace, the generated stack trace is coming from `new ErrorClass()` 50 | // instead of `normalizeException()`, since this is a nicer stack 51 | const normalizeAggregateErrors = ({ 52 | error, 53 | ErrorClass, 54 | UnknownError, 55 | parents, 56 | }) => { 57 | if (!Array.isArray(error?.errors)) { 58 | return 59 | } 60 | 61 | const parentsA = [...parents, error] 62 | const errors = error.errors 63 | .filter((aggregateError) => !parentsA.includes(aggregateError)) 64 | .map((aggregateError) => 65 | normalizeError({ 66 | error: aggregateError, 67 | ErrorClass, 68 | UnknownError, 69 | parents: parentsA, 70 | }), 71 | ) 72 | setNonEnumProp(error, 'errors', errors) 73 | } 74 | 75 | const shouldKeepClass = (error, ErrorClass, UnknownError) => 76 | error?.constructor === UnknownError || error instanceof ErrorClass 77 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "modern-errors", 3 | "projectOwner": "ehmicky", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "linkToUsage": false, 12 | "contributors": [ 13 | { 14 | "login": "ehmicky", 15 | "name": "ehmicky", 16 | "avatar_url": "https://avatars2.githubusercontent.com/u/8136211?v=4", 17 | "profile": "https://fosstodon.org/@ehmicky", 18 | "contributions": [ 19 | "code", 20 | "design", 21 | "ideas", 22 | "doc" 23 | ] 24 | }, 25 | { 26 | "login": "bhvngt", 27 | "name": "const_var", 28 | "avatar_url": "https://avatars.githubusercontent.com/u/79074469?v=4", 29 | "profile": "https://github.com/bhvngt", 30 | "contributions": [ 31 | "ideas", 32 | "question" 33 | ] 34 | }, 35 | { 36 | "login": "abrenneke", 37 | "name": "Andy Brenneke", 38 | "avatar_url": "https://avatars.githubusercontent.com/u/342540?v=4", 39 | "profile": "https://github.com/abrenneke", 40 | "contributions": [ 41 | "ideas", 42 | "question", 43 | "bug" 44 | ] 45 | }, 46 | { 47 | "login": "tgfisher4", 48 | "name": "Graham Fisher", 49 | "avatar_url": "https://avatars.githubusercontent.com/u/49082176?v=4", 50 | "profile": "https://github.com/tgfisher4", 51 | "contributions": [ 52 | "bug" 53 | ] 54 | }, 55 | { 56 | "login": "renzor-fist", 57 | "name": "renzor", 58 | "avatar_url": "https://avatars.githubusercontent.com/u/117486829?v=4", 59 | "profile": "https://github.com/renzor-fist", 60 | "contributions": [ 61 | "question", 62 | "ideas" 63 | ] 64 | }, 65 | { 66 | "login": "eugene1g", 67 | "name": "Eugene", 68 | "avatar_url": "https://avatars.githubusercontent.com/u/147496?v=4", 69 | "profile": "https://github.com/eugene1g", 70 | "contributions": [ 71 | "code", 72 | "bug" 73 | ] 74 | }, 75 | { 76 | "login": "jmchambers", 77 | "name": "Jonathan Chambers", 78 | "avatar_url": "https://avatars.githubusercontent.com/u/49592?v=4", 79 | "profile": "http://uk.linkedin.com/in/jonathanmarkchambers/", 80 | "contributions": [ 81 | "test", 82 | "bug" 83 | ] 84 | }, 85 | { 86 | "login": "heyhey123-git", 87 | "name": "heyhey123", 88 | "avatar_url": "https://avatars.githubusercontent.com/u/156066831?v=4", 89 | "profile": "https://github.com/heyhey123-git", 90 | "contributions": [ 91 | "bug" 92 | ] 93 | }, 94 | { 95 | "login": "benkroeger", 96 | "name": "Benjamin Kroeger", 97 | "avatar_url": "https://avatars.githubusercontent.com/u/7782055?v=4", 98 | "profile": "https://vegardit.com", 99 | "contributions": [ 100 | "bug" 101 | ] 102 | } 103 | ], 104 | "contributorsPerLine": 7, 105 | "skipCi": true, 106 | "commitConvention": "none", 107 | "commitType": "docs" 108 | } 109 | -------------------------------------------------------------------------------- /src/options/instance.d.ts: -------------------------------------------------------------------------------- 1 | import type { AggregateErrors } from '../merge/aggregate.js' 2 | import type { ErrorProps } from '../plugins/core/props/main.js' 3 | import type { Plugins } from '../plugins/shape/main.js' 4 | 5 | import type { PluginsOptions } from './plugins.js' 6 | 7 | /** 8 | * The `cause` option, as used when merged to the error instance 9 | */ 10 | export type NormalizedCause = CauseArg extends Error 11 | ? CauseArg 12 | : object 13 | 14 | /** 15 | * Optional `cause` option 16 | */ 17 | export type Cause = unknown 18 | 19 | /** 20 | * Options passed to error constructors, excluding any plugin options 21 | */ 22 | interface MainInstanceOptions< 23 | AggregateErrorsArg extends AggregateErrors, 24 | CauseArg extends Cause, 25 | > { 26 | /** 27 | * The `errors` option aggregates multiple errors into one. This is like 28 | * [`new AggregateError(errors)`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError/AggregateError) 29 | * except that it works with any error class. 30 | * 31 | * @example 32 | * ```js 33 | * const databaseError = new DatabaseError('...') 34 | * const authError = new AuthError('...') 35 | * throw new InputError('...', { errors: [databaseError, authError] }) 36 | * // InputError: ... { 37 | * // [errors]: [ 38 | * // DatabaseError: ... 39 | * // AuthError: ... 40 | * // ] 41 | * // } 42 | * ``` 43 | */ 44 | readonly errors?: AggregateErrorsArg 45 | 46 | /** 47 | * Any error's message, class and options can be wrapped using the 48 | * [standard `cause` option](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause). 49 | * 50 | * Instead of being set as a `cause` property, the inner error is directly 51 | * [merged](https://github.com/ehmicky/merge-error-cause) to the outer error, 52 | * including its 53 | * [`message`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/message), 54 | * [`stack`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/stack), 55 | * [`name`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/name), 56 | * [`AggregateError.errors`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError) 57 | * and any [additional property](#%EF%B8%8F-error-properties). 58 | * 59 | * @example 60 | * ```js 61 | * try { 62 | * // ... 63 | * } catch (cause) { 64 | * throw new InputError('Could not read the file.', { cause }) 65 | * } 66 | * ``` 67 | */ 68 | readonly cause?: CauseArg 69 | } 70 | 71 | /** 72 | * Options passed to error constructors, used internally only with additional 73 | * generics 74 | */ 75 | export type SpecificInstanceOptions< 76 | PluginsArg extends Plugins, 77 | ChildProps extends ErrorProps, 78 | AggregateErrorsArg extends AggregateErrors, 79 | CauseArg extends Cause, 80 | > = MainInstanceOptions & 81 | PluginsOptions 82 | 83 | /** 84 | * Options passed to error constructors: `new ErrorClass('message', options)` 85 | */ 86 | export type InstanceOptions = 87 | SpecificInstanceOptions 88 | -------------------------------------------------------------------------------- /src/options/class.test-d.ts: -------------------------------------------------------------------------------- 1 | import ModernError, { 2 | type ClassOptions, 3 | type InstanceOptions, 4 | } from 'modern-errors' 5 | import { expectAssignable, expectNotAssignable } from 'tsd' 6 | 7 | const plugin = { name: 'test' as const } 8 | const PluginBaseError = ModernError.subclass('PluginBaseError', { 9 | plugins: [plugin], 10 | }) 11 | const BaseError = ModernError.subclass('BaseError') 12 | const CustomError = ModernError.subclass('CustomError', { 13 | custom: class extends ModernError { 14 | one = true 15 | }, 16 | }) 17 | const DeepCustomError = CustomError.subclass('DeepCustomError', { 18 | custom: class extends CustomError { 19 | two = true 20 | }, 21 | }) 22 | 23 | // @ts-expect-error 24 | ModernError.subclass('TestError', true) 25 | // @ts-expect-error 26 | PluginBaseError.subclass('TestError', true) 27 | // @ts-expect-error 28 | BaseError.subclass('TestError', true) 29 | 30 | // @ts-expect-error 31 | ModernError.subclass('TestError', { other: true }) 32 | // @ts-expect-error 33 | PluginBaseError.subclass('TestError', { other: true }) 34 | // @ts-expect-error 35 | BaseError.subclass('TestError', { other: true }) 36 | 37 | // @ts-expect-error 38 | ModernError.subclass('TestError', { custom: true }) 39 | // @ts-expect-error 40 | PluginBaseError.subclass('TestError', { custom: true }) 41 | // @ts-expect-error 42 | BaseError.subclass('TestError', { custom: true }) 43 | expectNotAssignable({ custom: true }) 44 | 45 | // @ts-expect-error 46 | ModernError.subclass('TestError', { custom: class {} }) 47 | // @ts-expect-error 48 | PluginBaseError.subclass('TestError', { custom: class {} }) 49 | // @ts-expect-error 50 | BaseError.subclass('TestError', { custom: class {} }) 51 | 52 | // @ts-expect-error 53 | ModernError.subclass('TestError', { custom: class extends Object {} }) 54 | // @ts-expect-error 55 | PluginBaseError.subclass('TestError', { custom: class extends Object {} }) 56 | // @ts-expect-error 57 | BaseError.subclass('TestError', { custom: class extends Object {} }) 58 | 59 | // @ts-expect-error 60 | ModernError.subclass('TestError', { custom: class extends Error {} }) 61 | // @ts-expect-error 62 | PluginBaseError.subclass('TestError', { custom: class extends Error {} }) 63 | // @ts-expect-error 64 | BaseError.subclass('TestError', { custom: class extends Error {} }) 65 | 66 | // @ts-expect-error 67 | CustomError.subclass('TestError', { custom: class extends ModernError {} }) 68 | // @ts-expect-error 69 | DeepCustomError.subclass('TestError', { custom: class extends ModernError {} }) 70 | // @ts-expect-error 71 | DeepCustomError.subclass('TestError', { custom: class extends CustomError {} }) 72 | 73 | expectAssignable({ custom: ModernError }) 74 | expectNotAssignable({ custom: ModernError }) 75 | 76 | // @ts-expect-error 77 | ModernError.subclass('TestError', { plugins: true }) 78 | expectNotAssignable({ plugins: true }) 79 | // @ts-expect-error 80 | ModernError.subclass('TestError', { plugins: [true] }) 81 | expectNotAssignable({ plugins: [true] }) 82 | // @ts-expect-error 83 | ModernError.subclass('TestError', { plugins: [{}] }) 84 | expectNotAssignable({ plugins: [{}] }) 85 | 86 | expectAssignable({ plugins: [plugin] as const }) 87 | expectAssignable>({ plugins: [plugin] }) 88 | expectNotAssignable({ plugins: [plugin] }) 89 | -------------------------------------------------------------------------------- /src/plugins/properties/main.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses } from '../../helpers/main.test.js' 5 | import { ErrorSubclasses, TEST_PLUGIN } from '../../helpers/plugin.test.js' 6 | 7 | each(ErrorClasses, [undefined, true], ({ title }, ErrorClass, value) => { 8 | test(`plugin.properties() must return a plain object | ${title}`, (t) => { 9 | const TestError = ErrorClass.subclass('TestError', { 10 | plugins: [{ ...TEST_PLUGIN, properties: () => value }], 11 | }) 12 | t.throws(() => new TestError('test')) 13 | }) 14 | }) 15 | 16 | each(ErrorClasses, ({ title }, ErrorClass) => { 17 | test(`plugin.properties() is optional | ${title}`, (t) => { 18 | const TestError = ErrorClass.subclass('TestError', { 19 | plugins: [{ ...TEST_PLUGIN, properties: undefined }], 20 | }) 21 | t.false('properties' in new TestError('test')) 22 | }) 23 | 24 | test(`plugin.properties() can wrap error itself | ${title}`, (t) => { 25 | const prefix = 'prefix: ' 26 | const message = 'test' 27 | const plugin = { 28 | ...TEST_PLUGIN, 29 | properties: ({ error, ErrorClass: ErrorClassArg }) => { 30 | const wrappedError = error.message.startsWith(prefix) 31 | ? error 32 | : new ErrorClassArg(prefix, { cause: error }) 33 | return { wrappedError } 34 | }, 35 | } 36 | const TestError = ErrorClass.subclass('TestError', { plugins: [plugin] }) 37 | t.is(new TestError(message).wrappedError.message, `${prefix}${message}`) 38 | }) 39 | 40 | test(`plugin.properties() can modify the same properties | ${title}`, (t) => { 41 | const names = ['one', 'two'] 42 | const plugins = names.map((name) => ({ 43 | name, 44 | properties: ({ error }) => ({ message: `${error.message}${name}` }), 45 | })) 46 | const TestError = ErrorClass.subclass('TestError', { plugins }) 47 | const { message, stack } = new TestError('') 48 | t.is(message, names.join('')) 49 | t.true(stack.includes(names.join(''))) 50 | }) 51 | 52 | test(`plugin.properties() child plugins are not called when wrapping | ${title}`, (t) => { 53 | // eslint-disable-next-line fp/no-let 54 | let count = 0 55 | const TestError = ErrorClass.subclass('TestError', { 56 | plugins: [ 57 | { 58 | ...TEST_PLUGIN, 59 | properties: () => { 60 | // eslint-disable-next-line fp/no-mutation 61 | count += 1 62 | return { count } 63 | }, 64 | }, 65 | ], 66 | }) 67 | const cause = new TestError('causeMessage') 68 | t.is(cause.count, 1) 69 | const error = new ErrorClass('message', { cause }) 70 | t.is(error, cause) 71 | t.is(error.count, 1) 72 | }) 73 | }) 74 | 75 | each(ErrorSubclasses, ({ title }, ErrorClass) => { 76 | test(`plugin.properties() parent plugins are called when wrapping with options | ${title}`, (t) => { 77 | const cause = new ErrorClass('causeMessage', { prop: false }) 78 | const error = new ErrorClass('message', { cause, prop: true }) 79 | t.true(error.properties.options.prop) 80 | }) 81 | 82 | test(`plugin.properties() parent plugins are called when wrapping without options | ${title}`, (t) => { 83 | const cause = new ErrorClass('causeMessage', { prop: true }) 84 | // eslint-disable-next-line fp/no-delete 85 | delete cause.properties.options.prop 86 | const error = new ErrorClass('message', { cause, prop: undefined }) 87 | t.true(error.properties.options.prop) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /src/options/instance.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { ErrorClasses, ErrorSubclasses } from '../helpers/plugin.test.js' 5 | 6 | each(ErrorClasses, [undefined, {}], ({ title }, ErrorClass, opts) => { 7 | test(`Allows empty options | ${title}`, (t) => { 8 | t.notThrows(() => new ErrorClass('test', opts)) 9 | }) 10 | }) 11 | 12 | each( 13 | ErrorClasses, 14 | [null, '', { custom: true }], 15 | ({ title }, ErrorClass, opts) => { 16 | test(`Validate against invalid options | ${title}`, (t) => { 17 | t.throws(() => new ErrorClass('test', opts)) 18 | }) 19 | }, 20 | ) 21 | each(ErrorSubclasses, ({ title }, ErrorClass) => { 22 | test(`Does not set options if not defined | ${title}`, (t) => { 23 | t.is(new ErrorClass('test').properties.options.prop, undefined) 24 | }) 25 | 26 | test(`Sets options if defined | ${title}`, (t) => { 27 | t.true(new ErrorClass('test', { prop: true }).properties.options.prop) 28 | }) 29 | 30 | test(`Parent instance options are merged shallowly | ${title}`, (t) => { 31 | const cause = new ErrorClass('causeMessage', { 32 | prop: { one: false, two: false, four: { five: false } }, 33 | }) 34 | const error = new ErrorClass('test', { 35 | cause, 36 | prop: { two: true, three: true, four: { six: true } }, 37 | }) 38 | t.deepEqual(error.properties.options.prop, { 39 | one: false, 40 | two: true, 41 | three: true, 42 | four: { six: true }, 43 | }) 44 | }) 45 | 46 | test(`plugin.properties() cannot modify "options" passed to instance methods | ${title}`, (t) => { 47 | const error = new ErrorClass('test', { prop: { one: true } }) 48 | error.properties.options.prop.one = false 49 | t.true(error.getInstance().options.prop.one) 50 | }) 51 | 52 | test(`Instance options have priority over class options | ${title}`, (t) => { 53 | const OtherError = ErrorClass.subclass('TestError', { prop: false }) 54 | const cause = new OtherError('causeMessage') 55 | const error = new OtherError('test', { cause, prop: true }) 56 | t.true(error.properties.options.prop) 57 | }) 58 | 59 | test(`Undefined instance options are ignored | ${title}`, (t) => { 60 | const OtherError = ErrorClass.subclass('TestError', { prop: true }) 61 | const cause = new OtherError('causeMessage') 62 | const error = new OtherError('test', { cause, prop: undefined }) 63 | t.true(error.properties.options.prop) 64 | }) 65 | 66 | test(`Not defined instance options are ignored | ${title}`, (t) => { 67 | const OtherError = ErrorClass.subclass('TestError', { prop: true }) 68 | const error = new OtherError('test', { 69 | cause: new OtherError('causeMessage'), 70 | }) 71 | t.true(error.properties.options.prop) 72 | }) 73 | }) 74 | 75 | each( 76 | ErrorSubclasses, 77 | [{ prop: false }, {}, undefined], 78 | ({ title }, ErrorClass, opts) => { 79 | test(`Parent errors options has priority over child | ${title}`, (t) => { 80 | const cause = new ErrorClass('causeMessage', opts) 81 | const error = new ErrorClass('test', { cause, prop: true }) 82 | t.true(error.properties.options.prop) 83 | }) 84 | }, 85 | ) 86 | 87 | each(ErrorSubclasses, ErrorSubclasses, ({ title }, ErrorClass, ChildClass) => { 88 | test(`Child instance options are not unset | ${title}`, (t) => { 89 | const cause = new ChildClass('causeMessage', { prop: false }) 90 | const error = new ErrorClass('test', { cause }) 91 | t.false(error.properties.options.prop) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /src/options/plugins.test-d.ts: -------------------------------------------------------------------------------- 1 | import ModernError, { 2 | type ClassOptions, 3 | type InstanceOptions, 4 | type MethodOptions, 5 | type Plugin, 6 | } from 'modern-errors' 7 | import { expectAssignable, expectNotAssignable } from 'tsd' 8 | 9 | const barePlugin = { name: 'test' as const } 10 | const fullPlugin = { ...barePlugin, getOptions: (input: true) => input } 11 | 12 | const BaseError = ModernError.subclass('BaseError', { plugins: [fullPlugin] }) 13 | 14 | // @ts-expect-error 15 | ModernError.subclass('TestError', { plugins: [barePlugin], test: true }) 16 | ModernError.subclass('TestError', { plugins: [fullPlugin], test: true }) 17 | BaseError.subclass('TestError', { test: true }) 18 | new BaseError('', { test: true }) 19 | expectNotAssignable({ test: true }) 20 | expectAssignable>({ test: true }) 21 | expectNotAssignable({ test: true }) 22 | expectAssignable>({ test: true }) 23 | expectAssignable>(true) 24 | 25 | // @ts-expect-error 26 | ModernError.subclass('TestError', { plugins: [barePlugin], test: 'true' }) 27 | // @ts-expect-error 28 | ModernError.subclass('TestError', { plugins: [fullPlugin], test: 'true' }) 29 | // @ts-expect-error 30 | BaseError.subclass('TestError', { test: 'true' }) 31 | // @ts-expect-error 32 | new BaseError('', { test: 'true' }) 33 | expectNotAssignable({ test: 'true' }) 34 | expectNotAssignable>({ test: 'true' }) 35 | expectNotAssignable({ test: 'true' }) 36 | expectNotAssignable>({ test: 'true' }) 37 | expectNotAssignable>('true') 38 | 39 | // @ts-expect-error 40 | ModernError.subclass('TestError', { other: true }) 41 | // @ts-expect-error 42 | ModernError.subclass('TestError', { plugins: [barePlugin], other: true }) 43 | // @ts-expect-error 44 | ModernError.subclass('TestError', { plugins: [fullPlugin], other: true }) 45 | ModernError.subclass('TestError', { 46 | plugins: [fullPlugin as Plugin], 47 | // @ts-expect-error 48 | other: true, 49 | }) 50 | ModernError.subclass('TestError', { 51 | plugins: [{ ...fullPlugin, name: '' as string }], 52 | // @ts-expect-error 53 | other: true, 54 | }) 55 | // @ts-expect-error 56 | new BaseError('', { other: true }) 57 | expectNotAssignable({ other: true }) 58 | expectNotAssignable>({ other: true }) 59 | expectNotAssignable({ other: true }) 60 | expectNotAssignable>({ other: true }) 61 | 62 | ModernError.subclass('TestError', {}) 63 | ModernError.subclass('TestError', { plugins: [barePlugin] }) 64 | ModernError.subclass('TestError', { plugins: [fullPlugin] }) 65 | BaseError.subclass('TestError', {}) 66 | expectAssignable({}) 67 | expectAssignable>({}) 68 | expectAssignable({}) 69 | expectAssignable>({}) 70 | expectAssignable>({} as never) 71 | 72 | expectNotAssignable(true) 73 | expectNotAssignable>(true) 74 | expectNotAssignable(true) 75 | expectNotAssignable>(true) 76 | expectNotAssignable>(true) 77 | 78 | const secondPlugin = { ...fullPlugin, name: 'second' as const } 79 | const SecondPluginError = BaseError.subclass('SecondPluginError', { 80 | plugins: [secondPlugin], 81 | test: true, 82 | second: true, 83 | }) 84 | new SecondPluginError('', { test: true, second: true }) 85 | // @ts-expect-error 86 | new SecondPluginError('', { test: 'true' }) 87 | // @ts-expect-error 88 | new SecondPluginError('', { second: 'true' }) 89 | // @ts-expect-error 90 | new SecondPluginError('', { other: true }) 91 | -------------------------------------------------------------------------------- /src/plugins/core/props/main.test-d.ts: -------------------------------------------------------------------------------- 1 | import ModernError, { 2 | type ClassOptions, 3 | type InstanceOptions, 4 | } from 'modern-errors' 5 | import { expectAssignable, expectNotAssignable, expectType } from 'tsd' 6 | 7 | ModernError.subclass('TestError', { props: {} }) 8 | new ModernError('', { props: {} }) 9 | expectAssignable({ props: {} }) 10 | expectAssignable({ props: {} }) 11 | 12 | ModernError.subclass('TestError', { props: { prop: true } }) 13 | new ModernError('', { props: { prop: true } }) 14 | expectAssignable({ props: { prop: true } }) 15 | expectAssignable({ props: { prop: true } }) 16 | 17 | // @ts-expect-error 18 | ModernError.subclass('TestError', { props: true }) 19 | // @ts-expect-error 20 | new ModernError('', { props: true }) 21 | expectNotAssignable({ props: true }) 22 | expectNotAssignable({ props: true }) 23 | 24 | expectType(new ModernError('', { props: { one: true as const } }).one) 25 | expectAssignable<{ one: true; two: true }>( 26 | new ModernError('', { 27 | cause: new ModernError('', { props: { two: true as const } }), 28 | props: { one: true as const }, 29 | }), 30 | ) 31 | 32 | const BaseError = ModernError.subclass('BaseError') 33 | expectType(new BaseError('', { props: { one: true as const } }).one) 34 | expectAssignable<{ one: true; three: true }>( 35 | new BaseError('', { 36 | cause: new BaseError('', { 37 | props: { two: true as const, three: false as const }, 38 | }), 39 | props: { one: true as const, three: true as const }, 40 | }), 41 | ) 42 | 43 | const PropsError = ModernError.subclass('PropsError', { 44 | props: { one: true as const, three: false as const }, 45 | }) 46 | expectType(new PropsError('').one) 47 | expectAssignable<{ one: true; two: true; three: true }>( 48 | new PropsError('', { props: { two: true as const, three: true as const } }), 49 | ) 50 | expectAssignable<{ one: false; three: false }>( 51 | new PropsError('', { props: { one: false as const } }), 52 | ) 53 | const exception = {} as unknown 54 | 55 | if (exception instanceof PropsError) { 56 | expectAssignable<{ one: true; three: false }>(exception) 57 | } 58 | 59 | const ChildPropsError = PropsError.subclass('ChildPropsError') 60 | expectType(new ChildPropsError('').one) 61 | 62 | const DeepPropsError = PropsError.subclass('DeepPropsError', { 63 | props: { two: true as const, three: true as const }, 64 | }) 65 | expectAssignable<{ one: true; two: true; three: true }>(new DeepPropsError('')) 66 | 67 | const PropsChildError = BaseError.subclass('PropsChildError', { 68 | props: { one: true as const, three: false as const }, 69 | }) 70 | expectType(new PropsChildError('').one) 71 | expectAssignable<{ one: true; two: true; three: true }>( 72 | new PropsChildError('', { 73 | props: { two: true as const, three: true as const }, 74 | }), 75 | ) 76 | 77 | const BoundBaseError = ModernError.subclass('BoundBaseError', { 78 | props: { one: true as const, three: false as const }, 79 | }) 80 | expectType(new BoundBaseError('').one) 81 | expectAssignable<{ one: true; two: true; three: true }>( 82 | new BoundBaseError('', { 83 | props: { two: true as const, three: true as const }, 84 | }), 85 | ) 86 | 87 | const ChildBoundError = BoundBaseError.subclass('ChildBoundError') 88 | expectType(new ChildBoundError('').one) 89 | 90 | const DeepChildBoundError = ChildBoundError.subclass('DeepChildBoundError') 91 | expectType(new DeepChildBoundError('').one) 92 | 93 | const PropsBoundError = BoundBaseError.subclass('PropsBoundError', { 94 | props: { two: true as const, three: true as const }, 95 | }) 96 | expectAssignable<{ one: true; two: true; three: true }>(new PropsBoundError('')) 97 | 98 | const PropsChildBoundError = ChildBoundError.subclass('PropsChildBoundError', { 99 | props: { two: true as const, three: true as const }, 100 | }) 101 | expectAssignable<{ one: true; two: true; three: true }>( 102 | new PropsChildBoundError(''), 103 | ) 104 | -------------------------------------------------------------------------------- /src/plugins/info/main.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { 5 | getInstanceInfo, 6 | getMixInfo, 7 | getPropertiesInfo, 8 | getStaticInfo, 9 | } from '../../helpers/info.test.js' 10 | import { ErrorSubclasses, getPluginClasses } from '../../helpers/plugin.test.js' 11 | 12 | const { ErrorSubclasses: OtherSubclasses } = getPluginClasses() 13 | const { propertyIsEnumerable: isEnum } = Object.prototype 14 | 15 | each( 16 | ErrorSubclasses, 17 | [getPropertiesInfo, getInstanceInfo, getMixInfo, getStaticInfo], 18 | ({ title }, ErrorClass, getInfo) => { 19 | test(`plugin.properties|instanceMethods|staticMethods is passed ErrorClass | ${title}`, (t) => { 20 | t.is(getInfo(ErrorClass).ErrorClass, ErrorClass) 21 | }) 22 | 23 | test(`plugin.properties|instanceMethods|staticMethods cannot modify ErrorClasses | ${title}`, (t) => { 24 | const { ErrorClasses: ErrorClassesInfo } = getInfo(ErrorClass) 25 | const { length } = ErrorClassesInfo 26 | // eslint-disable-next-line fp/no-mutating-methods 27 | ErrorClassesInfo.push(true) 28 | t.is(ErrorClass.getProp().ErrorClasses.length, length) 29 | }) 30 | 31 | test(`plugin.properties|instanceMethods|staticMethods cannot modify options | ${title}`, (t) => { 32 | // eslint-disable-next-line fp/no-mutation, no-param-reassign 33 | getInfo(ErrorClass).options.prop = false 34 | t.is(ErrorClass.getProp().options.prop, undefined) 35 | }) 36 | 37 | test(`plugin.properties|instanceMethods|staticMethods has "full: true" with getOptions() | ${title}`, (t) => { 38 | t.true(getInfo(ErrorClass).options.full) 39 | }) 40 | 41 | test(`plugin.properties|instanceMethods|staticMethods is passed errorInfo | ${title}`, (t) => { 42 | t.is(typeof getInfo(ErrorClass).errorInfo, 'function') 43 | }) 44 | 45 | test(`plugin.properties|instanceMethods|staticMethods get the class options | ${title}`, (t) => { 46 | const TestError = ErrorClass.subclass('TestError', { prop: true }) 47 | t.true(getInfo(TestError).options.prop) 48 | }) 49 | 50 | test(`plugin.properties|instanceMethods|staticMethods get the instancesData | ${title}`, (t) => { 51 | const info = getInfo(ErrorClass) 52 | t.false(isEnum.call(info, 'instancesData')) 53 | const instanceOpts = { prop: true } 54 | const error = new ErrorClass('message', instanceOpts) 55 | t.deepEqual(info.instancesData.get(error).pluginsOpts, instanceOpts) 56 | }) 57 | }, 58 | ) 59 | 60 | each( 61 | OtherSubclasses, 62 | [getPropertiesInfo, getInstanceInfo, getMixInfo, getStaticInfo], 63 | ({ title }, ErrorClass, getInfo) => { 64 | const expectedErrorClasses = [ 65 | ErrorClass, 66 | ...OtherSubclasses.filter((ErrorSubclass) => 67 | Object.prototype.isPrototypeOf.call(ErrorClass, ErrorSubclass), 68 | ), 69 | ] 70 | 71 | test(`plugin.properties|instanceMethods|staticMethods is passed ErrorClasses | ${title}`, (t) => { 72 | t.deepEqual(getInfo(ErrorClass).ErrorClasses, expectedErrorClasses) 73 | }) 74 | 75 | test(`errorInfo is passed ErrorClasses | ${title}`, (t) => { 76 | const { errorInfo } = getInfo(ErrorClass) 77 | t.deepEqual( 78 | errorInfo(new ErrorClass('test')).ErrorClasses, 79 | expectedErrorClasses, 80 | ) 81 | }) 82 | }, 83 | ) 84 | 85 | each( 86 | ErrorSubclasses, 87 | [getPropertiesInfo, getInstanceInfo, getMixInfo], 88 | ({ title }, ErrorClass, getInfo) => { 89 | test(`plugin.properties|instanceMethods gets the instance options | ${title}`, (t) => { 90 | t.true(getInfo(ErrorClass, { prop: true }).options.prop) 91 | }) 92 | 93 | test(`plugin.properties|instanceMethods gets the error | ${title}`, (t) => { 94 | t.true(getInfo(ErrorClass, { prop: true }).error instanceof Error) 95 | }) 96 | }, 97 | ) 98 | 99 | each(ErrorSubclasses, ({ title }, ErrorClass) => { 100 | test(`plugin.properties is passed the final ErrorClass| ${title}`, (t) => { 101 | const TestError = ErrorClass.subclass('TestError') 102 | const cause = new TestError('causeMessage') 103 | t.is(new ErrorClass('test', { cause }).properties.ErrorClass, TestError) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /src/plugins/static/call.test-d.ts: -------------------------------------------------------------------------------- 1 | import ModernError, { type Info, type Plugin } from 'modern-errors' 2 | import { expectType } from 'tsd' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 5 | const name = 'test' as const 6 | const emptyPlugin = { name } 7 | const barePlugin = { 8 | ...emptyPlugin, 9 | staticMethods: { 10 | staticMethod: (infoArg: Info['staticMethods'], arg: '') => arg, 11 | }, 12 | } 13 | const fullPlugin = { 14 | ...barePlugin, 15 | getOptions: (input: true) => input, 16 | } 17 | 18 | const BareBaseError = ModernError.subclass('BareBaseError', { 19 | plugins: [barePlugin], 20 | }) 21 | const FullBaseError = ModernError.subclass('FullBaseError', { 22 | plugins: [fullPlugin], 23 | }) 24 | const MixBaseError = ModernError.subclass('MixBaseError', { 25 | plugins: [emptyPlugin, fullPlugin] as const, 26 | }) 27 | const BareChildError = BareBaseError.subclass('BareChildError') 28 | const FullChildError = FullBaseError.subclass('FullChildError') 29 | const MixChildError = MixBaseError.subclass('MixChildError') 30 | 31 | expectType<''>(BareBaseError.staticMethod('')) 32 | expectType<''>(FullBaseError.staticMethod('')) 33 | expectType<''>(MixBaseError.staticMethod('')) 34 | expectType<''>(BareChildError.staticMethod('')) 35 | expectType<''>(FullChildError.staticMethod('')) 36 | expectType<''>(MixChildError.staticMethod('')) 37 | // @ts-expect-error 38 | BareBaseError.staticMethod(true) 39 | // @ts-expect-error 40 | FullBaseError.staticMethod(true) 41 | // @ts-expect-error 42 | MixBaseError.staticMethod(true) 43 | // @ts-expect-error 44 | BareChildError.staticMethod(true) 45 | // @ts-expect-error 46 | FullChildError.staticMethod(true) 47 | // @ts-expect-error 48 | MixChildError.staticMethod(true) 49 | 50 | expectType<''>(FullBaseError.staticMethod('', true)) 51 | expectType<''>(FullChildError.staticMethod('', true)) 52 | // @ts-expect-error 53 | BareBaseError.staticMethod('', true) 54 | // @ts-expect-error 55 | BareBaseError.staticMethod('', undefined) 56 | // @ts-expect-error 57 | FullBaseError.staticMethod('', false) 58 | // @ts-expect-error 59 | FullBaseError.staticMethod('', undefined) 60 | // @ts-expect-error 61 | FullBaseError.staticMethod('', true, undefined) 62 | // @ts-expect-error 63 | MixBaseError.staticMethod('', false) 64 | // @ts-expect-error 65 | MixBaseError.staticMethod('', undefined) 66 | // @ts-expect-error 67 | MixBaseError.staticMethod('', true, undefined) 68 | // @ts-expect-error 69 | BareChildError.staticMethod('', true) 70 | // @ts-expect-error 71 | BareChildError.staticMethod('', undefined) 72 | // @ts-expect-error 73 | FullChildError.staticMethod('', false) 74 | // @ts-expect-error 75 | FullChildError.staticMethod('', undefined) 76 | // @ts-expect-error 77 | FullChildError.staticMethod('', true, undefined) 78 | // @ts-expect-error 79 | MixChildError.staticMethod('', false) 80 | // @ts-expect-error 81 | MixChildError.staticMethod('', undefined) 82 | // @ts-expect-error 83 | MixChildError.staticMethod('', true, undefined) 84 | 85 | const info = {} as Info['staticMethods'] 86 | // @ts-expect-error 87 | BareBaseError.staticMethod(info) 88 | // @ts-expect-error 89 | FullBaseError.staticMethod(info, '') 90 | // @ts-expect-error 91 | MixBaseError.staticMethod(info, '') 92 | // @ts-expect-error 93 | BareChildError.staticMethod(info) 94 | // @ts-expect-error 95 | FullChildError.staticMethod(info, '') 96 | // @ts-expect-error 97 | MixChildError.staticMethod(info, '') 98 | 99 | const WideBaseError = ModernError.subclass('WideBaseError', { 100 | plugins: [{} as Plugin], 101 | }) 102 | const ChildWideError = WideBaseError.subclass('ChildWideError') 103 | 104 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 105 | // @ts-expect-error 106 | BareBaseError.otherMethod() 107 | // @ts-expect-error 108 | FullBaseError.otherMethod() 109 | // @ts-expect-error 110 | MixBaseError.otherMethod() 111 | // @ts-expect-error 112 | WideBaseError.otherMethod() 113 | // @ts-expect-error 114 | BareChildError.otherMethod() 115 | // @ts-expect-error 116 | FullChildError.otherMethod() 117 | // @ts-expect-error 118 | MixChildError.otherMethod() 119 | // @ts-expect-error 120 | ChildWideError.otherMethod() 121 | 122 | // @ts-expect-error 123 | ChildWideError.staticMethod('') 124 | /* eslint-enable @typescript-eslint/no-unsafe-call */ 125 | -------------------------------------------------------------------------------- /src/plugins/info/error.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { each } from 'test-each' 3 | 4 | import { 5 | getInstanceInfo, 6 | getMixInfo, 7 | getPropertiesInfo, 8 | getStaticInfo, 9 | } from '../../helpers/info.test.js' 10 | import { ErrorSubclasses } from '../../helpers/plugin.test.js' 11 | import { getUnknownErrors } from '../../helpers/unknown.test.js' 12 | 13 | each( 14 | ErrorSubclasses, 15 | [getPropertiesInfo, getInstanceInfo, getMixInfo, getStaticInfo], 16 | getUnknownErrors(), 17 | // eslint-disable-next-line max-params 18 | ({ title }, ErrorClass, getInfo, getUnknownError) => { 19 | test(`errorInfo normalizes unknown errors | ${title}`, (t) => { 20 | const { errorInfo } = getInfo(ErrorClass) 21 | const info = errorInfo(getUnknownError()) 22 | t.is(info.error.constructor, ErrorClass) 23 | t.is(info.ErrorClass, ErrorClass) 24 | }) 25 | }, 26 | ) 27 | 28 | each( 29 | ErrorSubclasses, 30 | [(error) => error.properties, (error) => error.getInstance()], 31 | ({ title }, ErrorClass, getSpecifics) => { 32 | test(`errorInfo can be applied on error itself | ${title}`, (t) => { 33 | const error = new ErrorClass('test') 34 | const { errorInfo } = getSpecifics(error) 35 | t.is(errorInfo(error).error, error) 36 | }) 37 | }, 38 | ) 39 | 40 | each( 41 | ErrorSubclasses, 42 | [getPropertiesInfo, getInstanceInfo, getMixInfo, getStaticInfo], 43 | ({ title }, ErrorClass, getInfo) => { 44 | test(`errorInfo returns error | ${title}`, (t) => { 45 | const { errorInfo } = getInfo(ErrorClass) 46 | const error = new ErrorClass('test') 47 | t.is(errorInfo(error).error, error) 48 | }) 49 | 50 | test(`errorInfo returns ErrorClass | ${title}`, (t) => { 51 | const { errorInfo } = getInfo(ErrorClass) 52 | t.is(errorInfo(new ErrorClass('test')).ErrorClass, ErrorClass) 53 | }) 54 | 55 | test(`errorInfo returns ErrorClass of subclass | ${title}`, (t) => { 56 | const TestError = ErrorClass.subclass('TestError') 57 | const { errorInfo } = getInfo(ErrorClass) 58 | t.is(errorInfo(new TestError('test')).ErrorClass, TestError) 59 | }) 60 | 61 | test(`errorInfo returns instance options | ${title}`, (t) => { 62 | const { errorInfo } = getInfo(ErrorClass) 63 | t.true(errorInfo(new ErrorClass('test', { prop: true })).options.prop) 64 | }) 65 | 66 | test(`errorInfo returns class options | ${title}`, (t) => { 67 | const TestError = ErrorClass.subclass('TestError', { prop: true }) 68 | const { errorInfo } = getInfo(ErrorClass) 69 | const error = new TestError('test') 70 | t.true(errorInfo(error).options.prop) 71 | }) 72 | 73 | test(`errorInfo class options have less priority than instance options | ${title}`, (t) => { 74 | const TestError = ErrorClass.subclass('TestError', { prop: true }) 75 | const { errorInfo } = getInfo(TestError) 76 | t.false(errorInfo(new TestError('test', { prop: false })).options.prop) 77 | }) 78 | }, 79 | ) 80 | 81 | each( 82 | ErrorSubclasses, 83 | [getPropertiesInfo, getInstanceInfo, getMixInfo], 84 | ({ title }, ErrorClass, getInfo) => { 85 | test(`errorInfo ignores parent instance options | ${title}`, (t) => { 86 | const { errorInfo } = getInfo(ErrorClass, { prop: true }) 87 | t.is(errorInfo(new ErrorClass('test')).options.prop, undefined) 88 | }) 89 | }, 90 | ) 91 | 92 | each( 93 | ErrorSubclasses, 94 | [getInstanceInfo, getMixInfo, getStaticInfo], 95 | ({ title }, ErrorClass, getInfo) => { 96 | test(`errorInfo returns method options | ${title}`, (t) => { 97 | const { errorInfo } = getInfo(ErrorClass, {}, true) 98 | t.true(errorInfo(new ErrorClass('test')).options.prop) 99 | }) 100 | 101 | test(`errorInfo method options have more priority than instance options | ${title}`, (t) => { 102 | const { errorInfo } = getInfo(ErrorClass, {}, true) 103 | t.true(errorInfo(new ErrorClass('test', { prop: false })).options.prop) 104 | }) 105 | 106 | test(`errorInfo class options have less priority than method options | ${title}`, (t) => { 107 | const TestError = ErrorClass.subclass('TestError', { prop: false }) 108 | const { errorInfo } = getInfo(TestError, {}, true) 109 | t.true(errorInfo(new TestError('test')).options.prop) 110 | }) 111 | }, 112 | ) 113 | -------------------------------------------------------------------------------- /src/main.d.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorClass, SpecificErrorClass } from './subclass/create.js' 2 | import type { CustomClass } from './subclass/custom.js' 3 | 4 | export type { ErrorInstance } from './merge/cause.js' 5 | export type { ClassOptions } from './options/class.js' 6 | export type { InstanceOptions } from './options/instance.js' 7 | export type { MethodOptions } from './options/method.js' 8 | export type { Info } from './plugins/info/main.js' 9 | export type { Plugin } from './plugins/shape/main.js' 10 | export type { ErrorClass } 11 | 12 | /** 13 | * Top-level `ErrorClass`. 14 | * 15 | * @example 16 | * ```js 17 | * export const BaseError = ModernError.subclass('BaseError') 18 | * 19 | * export const UnknownError = BaseError.subclass('UnknownError') 20 | * export const InputError = BaseError.subclass('InputError') 21 | * export const AuthError = BaseError.subclass('AuthError') 22 | * export const DatabaseError = BaseError.subclass('DatabaseError') 23 | * ``` 24 | */ 25 | declare const ModernError: SpecificErrorClass<[], object, CustomClass> 26 | export default ModernError 27 | 28 | // Major limitations of current types: 29 | // - Plugin methods cannot be generic 30 | // - Plugin types can use `ErrorClass` or `Info`, but not export them. 31 | // See the comment in info.d.ts for an explanation. 32 | // Medium limitations: 33 | // - Some logic relies on determining if an error class is a subclass of 34 | // another 35 | // - However, this is not perfectly possible with TypeScript since it is 36 | // based on structural typing 37 | // - Unrelated classes will be considered identical if they have the same 38 | // options 39 | // - The `props` and `plugins` option do manage to create proper 40 | // inheritance, but not the `custom` option 41 | // - This impacts: 42 | // - `ErrorClass.normalize()` second argument might not always fail when 43 | // it is not a subclass of `ErrorClass` 44 | // - `ErrorClass.normalize(new ErrorClass(''), ErrorSubClass)` returns 45 | // an instance of `ErrorClass` instead of `ErrorSubclass` 46 | // - If two `plugin.properties()` (or `props`) return the same property, they 47 | // are intersected using `&`, instead of the second one overriding the first 48 | // - Therefore, the type of `plugin.properties()` that are not unique should 49 | // currently be wide to avoid the `&` intersection resulting in 50 | // `undefined` 51 | // - This problem does not apply to error core properties (`message` and 52 | // `stack`) which are always kept correct 53 | // - Type narrowing with `instanceof` does not work if there are any plugins 54 | // with instance|static methods. This is due to the following bug: 55 | // https://github.com/microsoft/TypeScript/issues/50844 56 | // - When a `custom` class overrides a plugin's instance method, it must be 57 | // set as a class property `methodName = (...) => ...` instead of as a 58 | // method `methodName(...) { ... }`. This is due to the following bug: 59 | // https://github.com/microsoft/TypeScript/issues/48125 60 | // - When a `custom` class overrides a core error property, a plugin's 61 | // `instanceMethods`, `properties()` or `props`, it should work even if it is 62 | // not a subtype of it 63 | // - `ErrorClass.subclass(..., { custom })` should fail if `custom` is not 64 | // directly extending from `ErrorClass`, but it currently always succeed 65 | // except when either: 66 | // - `custom` class is not a `ModernError` 67 | // - `ErrorClass` (or a parent) has a `custom` class itself 68 | // - Defining the same plugin twice should fail, but it is a noop instead 69 | // Minor limitations: 70 | // - Plugin instance|static methods should not be allowed to override `Error.*` 71 | // (e.g. `prepareStackTrace()`) 72 | // - Plugins should not be allowed to define instance|static methods already 73 | // defined by other plugins 74 | 75 | // NOTE: The following exports are temporary workarounds for: 76 | // - [modern-errors issue #18](https://github.com/ehmicky/modern-errors/issues/18) 77 | // - [TypeScript issue #47663](https://github.com/microsoft/TypeScript/issues/47663) 78 | export type { CommonInfo } from './plugins/info/main.js' 79 | export type { CustomClass } from './subclass/custom.js' 80 | export type { CreateSubclass, ErrorSubclassCore } from './subclass/create.js' 81 | export type { NormalizeError } from './subclass/normalize.js' 82 | export type { SpecificErrorClass } 83 | -------------------------------------------------------------------------------- /src/subclass/normalize.test-d.ts: -------------------------------------------------------------------------------- 1 | import ModernError, { type Plugin } from 'modern-errors' 2 | import { expectAssignable, expectType } from 'tsd' 3 | 4 | const ParentError = ModernError.subclass('ParentError', { 5 | props: { one: true as const }, 6 | }) 7 | const ChildError = ParentError.subclass('ChildError', { 8 | props: { two: true as const }, 9 | }) 10 | 11 | type ParentErrorInstance = InstanceType 12 | 13 | type ChildErrorInstance = InstanceType 14 | 15 | expectAssignable(ParentError.normalize(new Error(''))) 16 | expectType(ParentError.normalize({})) 17 | expectType(ParentError.normalize('')) 18 | expectType(ParentError.normalize(undefined)) 19 | expectAssignable( 20 | ParentError.normalize(undefined, ChildError), 21 | ) 22 | 23 | expectType( 24 | ParentError.normalize(new Error('') as Error & { prop: true }).prop, 25 | ) 26 | expectType( 27 | ParentError.normalize(new Error('') as Error & { prop: true }, ChildError) 28 | .prop, 29 | ) 30 | expectType( 31 | ParentError.normalize(new ModernError('', { props: { prop: true as const } })) 32 | .prop, 33 | ) 34 | expectType( 35 | ParentError.normalize(new ModernError('', { props: { one: false as const } })) 36 | .one, 37 | ) 38 | // @ts-expect-error 39 | ParentError.normalize({ prop: true }).prop 40 | 41 | expectType(ParentError.normalize(new ParentError(''))) 42 | expectType(ParentError.normalize(new ChildError(''))) 43 | expectType( 44 | ParentError.normalize(new ChildError(''), ChildError), 45 | ) 46 | expectType(ChildError.normalize(new ParentError(''))) 47 | expectType( 48 | ChildError.normalize(new ParentError(''), ChildError), 49 | ) 50 | 51 | const CustomError = ModernError.subclass('CustomError', { 52 | custom: class extends ModernError { 53 | prop = true 54 | }, 55 | }) 56 | const PropsError = ModernError.subclass('PropsError', { 57 | props: { one: true as const }, 58 | }) 59 | type PropsErrorInstance = InstanceType 60 | 61 | const customError = new CustomError('') 62 | type CustomInstance = (typeof CustomError)['prototype'] 63 | 64 | expectAssignable(new ModernError('', { cause: customError })) 65 | expectType(ModernError.normalize(customError)) 66 | 67 | const PluginBaseError = ModernError.subclass('PluginBaseError', { 68 | plugins: [{ name: 'test' as const }], 69 | }) 70 | const PluginCustomError = PluginBaseError.subclass('PluginCustomError', { 71 | custom: class extends PluginBaseError { 72 | prop = true 73 | }, 74 | }) 75 | const pluginCustomError = new PluginCustomError('') 76 | type PluginCustomInstance = (typeof PluginCustomError)['prototype'] 77 | 78 | expectAssignable( 79 | new PluginBaseError('', { cause: pluginCustomError }), 80 | ) 81 | expectType(PluginBaseError.normalize(pluginCustomError)) 82 | 83 | const WideError = ModernError.subclass('WideError', { plugins: [{} as Plugin] }) 84 | 85 | expectAssignable( 86 | new WideError('', { cause: pluginCustomError }), 87 | ) 88 | expectType(WideError.normalize(pluginCustomError)) 89 | 90 | const cause = {} as Error & { prop: true } 91 | expectType(new ModernError('', { cause }).prop) 92 | expectType(ModernError.normalize(cause).prop) 93 | 94 | ModernError.normalize('', ModernError) 95 | ModernError.normalize('', PropsError) 96 | // @ts-expect-error 97 | ModernError.normalize() 98 | // @ts-expect-error 99 | ModernError.normalize('', true) 100 | // @ts-expect-error 101 | ModernError.normalize('', Error) 102 | // @ts-expect-error 103 | PropsError.normalize('', ModernError) 104 | 105 | expectType( 106 | PropsError.normalize( 107 | new PropsError('', { errors: [''] as readonly string[] }), 108 | ).errors, 109 | ) 110 | const parentAggregateError = new PropsError('', { 111 | errors: [''] as readonly [''], 112 | }) 113 | expectType<[PropsErrorInstance]>( 114 | PropsError.normalize(parentAggregateError).errors, 115 | ) 116 | expectType<[PropsErrorInstance]>( 117 | PropsError.normalize( 118 | new PropsError('', { 119 | errors: [parentAggregateError] as [typeof parentAggregateError], 120 | }), 121 | ).errors[0].errors, 122 | ) 123 | expectType(PropsError.normalize(new PropsError('')).errors) 124 | -------------------------------------------------------------------------------- /src/plugins/instance/call.test-d.ts: -------------------------------------------------------------------------------- 1 | import ModernError, { type Info, type Plugin } from 'modern-errors' 2 | import { expectType } from 'tsd' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 5 | const name = 'test' as const 6 | const emptyPlugin = { name } 7 | const barePlugin = { 8 | ...emptyPlugin, 9 | instanceMethods: { 10 | instanceMethod: (infoArg: Info['instanceMethods'], arg: '') => arg, 11 | }, 12 | } 13 | const fullPlugin = { 14 | ...barePlugin, 15 | getOptions: (input: true) => input, 16 | } 17 | 18 | const BareBaseError = ModernError.subclass('BareBaseError', { 19 | plugins: [barePlugin], 20 | }) 21 | const FullBaseError = ModernError.subclass('FullBaseError', { 22 | plugins: [fullPlugin], 23 | }) 24 | const MixBaseError = ModernError.subclass('MixBaseError', { 25 | plugins: [emptyPlugin, fullPlugin] as const, 26 | }) 27 | const BareChildError = BareBaseError.subclass('BareChildError') 28 | const FullChildError = FullBaseError.subclass('FullChildError') 29 | const MixChildError = MixBaseError.subclass('MixChildError') 30 | const bareUnknownError = new BareBaseError('') 31 | const fullUnknownError = new FullBaseError('') 32 | const mixUnknownError = new MixBaseError('') 33 | const bareChildError = new BareChildError('') 34 | const fullChildError = new FullChildError('') 35 | const mixChildError = new MixChildError('') 36 | 37 | expectType<''>(bareUnknownError.instanceMethod('')) 38 | expectType<''>(bareChildError.instanceMethod('')) 39 | expectType<''>(fullUnknownError.instanceMethod('')) 40 | expectType<''>(fullChildError.instanceMethod('')) 41 | expectType<''>(mixUnknownError.instanceMethod('')) 42 | expectType<''>(mixChildError.instanceMethod('')) 43 | // @ts-expect-error 44 | bareUnknownError.instanceMethod(true) 45 | // @ts-expect-error 46 | bareChildError.instanceMethod(true) 47 | // @ts-expect-error 48 | fullUnknownError.instanceMethod(true) 49 | // @ts-expect-error 50 | fullChildError.instanceMethod(true) 51 | // @ts-expect-error 52 | mixUnknownError.instanceMethod(true) 53 | // @ts-expect-error 54 | mixChildError.instanceMethod(true) 55 | 56 | expectType<''>(fullUnknownError.instanceMethod('', true)) 57 | expectType<''>(fullChildError.instanceMethod('', true)) 58 | expectType<''>(mixUnknownError.instanceMethod('', true)) 59 | expectType<''>(mixChildError.instanceMethod('', true)) 60 | // @ts-expect-error 61 | bareUnknownError.instanceMethod('', true) 62 | // @ts-expect-error 63 | bareChildError.instanceMethod('', true) 64 | // @ts-expect-error 65 | bareUnknownError.instanceMethod('', undefined) 66 | // @ts-expect-error 67 | bareChildError.instanceMethod('', undefined) 68 | // @ts-expect-error 69 | fullUnknownError.instanceMethod('', false) 70 | // @ts-expect-error 71 | fullChildError.instanceMethod('', false) 72 | // @ts-expect-error 73 | fullUnknownError.instanceMethod('', undefined) 74 | // @ts-expect-error 75 | fullChildError.instanceMethod('', undefined) 76 | // @ts-expect-error 77 | fullUnknownError.instanceMethod('', true, undefined) 78 | // @ts-expect-error 79 | fullChildError.instanceMethod('', true, undefined) 80 | // @ts-expect-error 81 | mixUnknownError.instanceMethod('', false) 82 | // @ts-expect-error 83 | mixChildError.instanceMethod('', false) 84 | // @ts-expect-error 85 | mixUnknownError.instanceMethod('', undefined) 86 | // @ts-expect-error 87 | mixChildError.instanceMethod('', undefined) 88 | // @ts-expect-error 89 | mixUnknownError.instanceMethod('', true, undefined) 90 | // @ts-expect-error 91 | mixChildError.instanceMethod('', true, undefined) 92 | 93 | const info = {} as Info['instanceMethods'] 94 | // @ts-expect-error 95 | bareUnknownError.instanceMethod(info) 96 | // @ts-expect-error 97 | bareChildError.instanceMethod(info) 98 | // @ts-expect-error 99 | fullUnknownError.instanceMethod(info, '') 100 | // @ts-expect-error 101 | fullChildError.instanceMethod(info, '') 102 | // @ts-expect-error 103 | mixUnknownError.instanceMethod(info, '') 104 | // @ts-expect-error 105 | mixChildError.instanceMethod(info, '') 106 | 107 | const WideBaseError = ModernError.subclass('WideBaseError', { 108 | plugins: [{} as Plugin], 109 | }) 110 | const ChildWideError = WideBaseError.subclass('ChildWideError') 111 | const unknownWideError = new WideBaseError('') 112 | const childWideError = new ChildWideError('') 113 | 114 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 115 | // @ts-expect-error 116 | bareUnknownError.otherMethod() 117 | // @ts-expect-error 118 | bareChildError.otherMethod() 119 | // @ts-expect-error 120 | fullUnknownError.otherMethod() 121 | // @ts-expect-error 122 | fullChildError.otherMethod() 123 | // @ts-expect-error 124 | mixUnknownError.otherMethod() 125 | // @ts-expect-error 126 | mixChildError.otherMethod() 127 | // @ts-expect-error 128 | unknownWideError.otherMethod() 129 | // @ts-expect-error 130 | childWideError.otherMethod() 131 | /* eslint-enable @typescript-eslint/no-unsafe-call */ 132 | -------------------------------------------------------------------------------- /src/plugins/instance/mixed.test-d.ts: -------------------------------------------------------------------------------- 1 | import ModernError, { type Info, type Plugin } from 'modern-errors' 2 | import { expectType } from 'tsd' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 5 | const name = 'test' as const 6 | const emptyPlugin = { name } 7 | const barePlugin = { 8 | ...emptyPlugin, 9 | instanceMethods: { 10 | instanceMethod: (infoArg: Info['instanceMethods'], arg: '') => arg, 11 | }, 12 | } 13 | const fullPlugin = { 14 | ...barePlugin, 15 | getOptions: (input: true) => input, 16 | } 17 | 18 | const BareBaseError = ModernError.subclass('BareBaseError', { 19 | plugins: [barePlugin], 20 | }) 21 | const FullBaseError = ModernError.subclass('FullBaseError', { 22 | plugins: [fullPlugin], 23 | }) 24 | const MixBaseError = ModernError.subclass('MixBaseError', { 25 | plugins: [emptyPlugin, fullPlugin] as const, 26 | }) 27 | const BareChildError = BareBaseError.subclass('BareChildError') 28 | const FullChildError = FullBaseError.subclass('FullChildError') 29 | const MixChildError = MixBaseError.subclass('MixChildError') 30 | 31 | expectType<''>(BareBaseError.instanceMethod(undefined, '')) 32 | expectType<''>(FullBaseError.instanceMethod(undefined, '')) 33 | expectType<''>(MixBaseError.instanceMethod(undefined, '')) 34 | expectType<''>(BareChildError.instanceMethod(undefined, '')) 35 | expectType<''>(FullChildError.instanceMethod(undefined, '')) 36 | expectType<''>(MixChildError.instanceMethod(undefined, '')) 37 | 38 | const error = new Error('test') 39 | 40 | expectType<''>(BareBaseError.instanceMethod(error, '')) 41 | expectType<''>(FullBaseError.instanceMethod(error, '')) 42 | expectType<''>(MixBaseError.instanceMethod(error, '')) 43 | expectType<''>(BareChildError.instanceMethod(error, '')) 44 | expectType<''>(FullChildError.instanceMethod(error, '')) 45 | expectType<''>(MixChildError.instanceMethod(error, '')) 46 | // @ts-expect-error 47 | BareBaseError.instanceMethod(error, true) 48 | // @ts-expect-error 49 | FullBaseError.instanceMethod(error, true) 50 | // @ts-expect-error 51 | MixBaseError.instanceMethod(error, true) 52 | // @ts-expect-error 53 | BareChildError.instanceMethod(error, true) 54 | // @ts-expect-error 55 | FullChildError.instanceMethod(error, true) 56 | // @ts-expect-error 57 | MixChildError.instanceMethod(error, true) 58 | 59 | expectType<''>(FullBaseError.instanceMethod(error, '', true)) 60 | expectType<''>(FullChildError.instanceMethod(error, '', true)) 61 | // @ts-expect-error 62 | BareBaseError.instanceMethod(error, '', true) 63 | // @ts-expect-error 64 | BareBaseError.instanceMethod(error, '', undefined) 65 | // @ts-expect-error 66 | FullBaseError.instanceMethod(error, '', false) 67 | // @ts-expect-error 68 | FullBaseError.instanceMethod(error, '', undefined) 69 | // @ts-expect-error 70 | FullBaseError.instanceMethod(error, '', true, undefined) 71 | // @ts-expect-error 72 | MixBaseError.instanceMethod(error, '', false) 73 | // @ts-expect-error 74 | MixBaseError.instanceMethod(error, '', undefined) 75 | // @ts-expect-error 76 | MixBaseError.instanceMethod(error, '', true, undefined) 77 | // @ts-expect-error 78 | BareChildError.instanceMethod(error, '', true) 79 | // @ts-expect-error 80 | BareChildError.instanceMethod(error, '', undefined) 81 | // @ts-expect-error 82 | FullChildError.instanceMethod(error, '', false) 83 | // @ts-expect-error 84 | FullChildError.instanceMethod(error, '', undefined) 85 | // @ts-expect-error 86 | FullChildError.instanceMethod(error, '', true, undefined) 87 | // @ts-expect-error 88 | MixChildError.instanceMethod(error, '', false) 89 | // @ts-expect-error 90 | MixChildError.instanceMethod(error, '', undefined) 91 | // @ts-expect-error 92 | MixChildError.instanceMethod(error, '', true, undefined) 93 | 94 | const info = {} as Info['instanceMethods'] 95 | // @ts-expect-error 96 | BareBaseError.instanceMethod(error, info) 97 | // @ts-expect-error 98 | FullBaseError.instanceMethod(error, info, '') 99 | // @ts-expect-error 100 | MixBaseError.instanceMethod(error, info, '') 101 | // @ts-expect-error 102 | BareChildError.instanceMethod(error, info) 103 | // @ts-expect-error 104 | FullChildError.instanceMethod(error, info, '') 105 | // @ts-expect-error 106 | MixChildError.instanceMethod(error, info, '') 107 | 108 | const WideBaseError = ModernError.subclass('WideBaseError', { 109 | plugins: [{} as Plugin], 110 | }) 111 | const ChildWideError = WideBaseError.subclass('ChildWideError') 112 | 113 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 114 | // @ts-expect-error 115 | BareBaseError.otherMethod() 116 | // @ts-expect-error 117 | FullBaseError.otherMethod() 118 | // @ts-expect-error 119 | MixBaseError.otherMethod() 120 | // @ts-expect-error 121 | WideBaseError.otherMethod() 122 | // @ts-expect-error 123 | BareChildError.otherMethod() 124 | // @ts-expect-error 125 | FullChildError.otherMethod() 126 | // @ts-expect-error 127 | MixChildError.otherMethod() 128 | // @ts-expect-error 129 | ChildWideError.otherMethod() 130 | 131 | // @ts-expect-error 132 | ChildWideError.instanceMethod(error, '') 133 | /* eslint-enable @typescript-eslint/no-unsafe-call */ 134 | -------------------------------------------------------------------------------- /src/subclass/create.d.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorName } from 'error-custom-class' 2 | 3 | import type { AggregateErrors } from '../merge/aggregate.js' 4 | import type { SpecificErrorInstance } from '../merge/cause.js' 5 | import type { SpecificClassOptions } from '../options/class.js' 6 | import type { Cause } from '../options/instance.js' 7 | import type { ErrorProps, MergeErrorProps } from '../plugins/core/props/main.js' 8 | import type { PluginsMixedMethods } from '../plugins/instance/mixed.js' 9 | import type { Plugins } from '../plugins/shape/main.js' 10 | import type { PluginsStaticMethods } from '../plugins/static/call.js' 11 | import type { OmitKeys } from '../utils/omit.js' 12 | 13 | import type { 14 | CustomClass, 15 | ParentExtra, 16 | ParentInstanceOptions, 17 | } from './custom.js' 18 | import type { NormalizeError } from './normalize.js' 19 | 20 | /** 21 | * `ErrorClass.subclass()` 22 | * 23 | * @private This type is private and only exported as a temporary workaround 24 | * for an open issue with TypeScript. It will be removed in a future release. 25 | * See: 26 | * 27 | * - [modern-errors issue #18](https://github.com/ehmicky/modern-errors/issues/18) 28 | * - [TypeScript issue #47663](https://github.com/microsoft/TypeScript/issues/47663) 29 | */ 30 | type CreateSubclass< 31 | PluginsArg extends Plugins, 32 | ErrorPropsArg extends ErrorProps, 33 | CustomClassArg extends CustomClass, 34 | > = < 35 | ChildPlugins extends Plugins = [], 36 | ChildCustomClass extends CustomClassArg = CustomClassArg, 37 | ChildProps extends ErrorProps = object, 38 | >( 39 | errorName: ErrorName, 40 | options?: SpecificClassOptions< 41 | PluginsArg, 42 | ChildPlugins, 43 | ChildProps, 44 | ChildCustomClass 45 | >, 46 | ) => SpecificErrorClass< 47 | [...PluginsArg, ...ChildPlugins], 48 | MergeErrorProps, 49 | ChildCustomClass 50 | > 51 | 52 | /** 53 | * Non-dynamic members of error classes 54 | * 55 | * @private This type is private and only exported as a temporary workaround 56 | * for an open issue with TypeScript. It will be removed in a future release. 57 | * See: 58 | * 59 | * - [modern-errors issue #18](https://github.com/ehmicky/modern-errors/issues/18) 60 | * - [TypeScript issue #47663](https://github.com/microsoft/TypeScript/issues/47663) 61 | */ 62 | interface ErrorSubclassCore< 63 | PluginsArg extends Plugins, 64 | ErrorPropsArg extends ErrorProps, 65 | CustomClassArg extends CustomClass, 66 | > { 67 | /** 68 | * Error subclass 69 | * 70 | * @example 71 | * ```js 72 | * throw new InputError('Missing file path.') 73 | * ``` 74 | */ 75 | new < 76 | ChildProps extends ErrorProps = ErrorProps, 77 | AggregateErrorsArg extends AggregateErrors = AggregateErrors, 78 | CauseArg extends Cause = Cause, 79 | >( 80 | message: string, 81 | options?: ParentInstanceOptions< 82 | PluginsArg, 83 | ChildProps, 84 | CustomClassArg, 85 | AggregateErrorsArg, 86 | CauseArg 87 | >, 88 | ...extra: ParentExtra 89 | ): SpecificErrorInstance< 90 | PluginsArg, 91 | MergeErrorProps, 92 | CustomClassArg, 93 | AggregateErrorsArg, 94 | CauseArg 95 | > 96 | 97 | readonly prototype: InstanceType< 98 | ErrorSubclassCore 99 | > 100 | 101 | /** 102 | * Creates and returns a child `ErrorClass`. 103 | * 104 | * @example 105 | * ```js 106 | * export const InputError = ErrorClass.subclass('InputError', options) 107 | * ``` 108 | */ 109 | subclass: CreateSubclass 110 | 111 | /** 112 | * Normalizes invalid errors. 113 | * 114 | * If `error` is an instance of `ErrorClass` (or one of its subclasses), it is 115 | * left as is. Otherwise, it is converted to `NewErrorClass`, which defaults 116 | * to `ErrorClass` itself. 117 | * 118 | * @example 119 | * ```js 120 | * try { 121 | * throw 'Missing file path.' 122 | * } catch (invalidError) { 123 | * const normalizedError = BaseError.normalize(invalidError) 124 | * // This works: 'Missing file path.' 125 | * // `normalizedError` is a `BaseError` instance. 126 | * console.log(normalizedError.message.trim()) 127 | * } 128 | * ``` 129 | */ 130 | normalize: NormalizeError 131 | } 132 | 133 | /** 134 | * Error class, with specific `props`, `custom`, etc. 135 | * 136 | * @private This type is private and only exported as a temporary workaround 137 | * for an open issue with TypeScript. It will be removed in a future release. 138 | * See: 139 | * 140 | * - [modern-errors issue #35](https://github.com/ehmicky/modern-errors/issues/35) 141 | * - [modern-errors issue #18](https://github.com/ehmicky/modern-errors/issues/18) 142 | * - [TypeScript issue #47663](https://github.com/microsoft/TypeScript/issues/47663) 143 | */ 144 | export type SpecificErrorClass< 145 | PluginsArg extends Plugins, 146 | ErrorPropsArg extends ErrorProps, 147 | CustomClassArg extends CustomClass, 148 | > = ErrorSubclassCore & 149 | OmitKeys< 150 | CustomClassArg, 151 | keyof ErrorSubclassCore 152 | > & 153 | PluginsStaticMethods & 154 | PluginsMixedMethods 155 | 156 | /** 157 | * Error class 158 | */ 159 | export type ErrorClass = 160 | SpecificErrorClass 161 | --------------------------------------------------------------------------------