modern-errors

repository·main·Indexed 23 days ago

https://github.com/ehmicky/modern-errors

A utility library for creating, managing, and normalizing structured error classes in JavaScript and TypeScript. It provides features for error wrapping via the cause option, property inheritance through subclassing, error aggregation, and normalization of invalid errors. The library supports a plugin system to extend error functionality with custom properties and methods. Requires Node.js >= 18.18.0 and uses ES Modules.

Tokens
12.4K
Snippets
31
Records
55
Agent score
81%

What's inside modern-errors

  1. Retrieve error class and instance types

    main

    modern-errors relies heavily on TypeScript's type inference. You can use standard utility types to retrieve the types of error classes and instances:

    • typeof ExampleError: Returns the error class type.
    • InstanceType<typeof ExampleError>: Returns the error instance type.

    This is useful for typing functions that accept error classes or specific error instances.

    type AnyErrorClass = ReturnType<typeof BaseError.subclass>
    
    const InputError = BaseError.subclass('InputError')
    type InputErrorClass = typeof InputError
    
    type InputErrorInstance = InstanceType<typeof InputErrorClass>
    
    const printErrorClass = (ErrorClass: AnyErrorClass) => {
      // ...
    }
    
    const printInputErrorClass = (InputErrorClass: InputErrorClass) => {
      // ...
    }
    
    const logInputError = (inputError: InputErrorInstance) => {
      // ...
    }
    
    printErrorClass(InputError)
    printInputErrorClass(InputError)
    logInputError(new InputError('Wrong user name'))
  2. Wrap errors using the cause option

    main

    You can wrap an existing error inside a new error using the cause option. modern-errors enhances the standard JavaScript cause behavior by directly merging the inner error into the outer error. This includes merging:

    • message
    • stack
    • name
    • AggregateError.errors
    • Additional properties from props

    Message Formatting: The outer error message is appended to the inner error message. If the outer message ends with : or :\n, it is prepended instead to maintain clean formatting.

    try {
      // ...
    } catch (cause) {
      // The inner error's message and stack are merged into InputError
      throw new InputError('Could not read the file.', { cause })
    }
  3. Use type inference for error properties and custom methods

    main

    modern-errors automatically infers types for error properties (props), aggregate errors, and custom methods/properties defined in the custom block. You do not need to provide explicit type declarations for these values.

    When using subclass, the resulting error instance will have correctly typed properties based on the values provided in the configuration.

    const BaseError = ModernError.subclass('BaseError', {
      props: { userId: 5 as const },
      custom: class extends ModernError {
        isUserInput() {
          return true as const
        }
      },
    })
    const error = new BaseError('Wrong user name', {
      props: { userName: 'Alice' as const },
    })
    const { userId, userName } = error // Inferred type: `5` and `"Alice""
    const result = error.isUserInput() // Inferred type: `true`
  4. Best practices for plugin options

    main

    To ensure compatibility with serialization and consistent typing, follow these best practices for plugin options:

    • Prefer Serializable Options: Use JSON-serializable types. Avoid functions and class instances in options to ensure they can be preserved when errors are serialized/parsed via modern-errors-serialize.
    • Use the getOptions pattern: Always use the getOptions and info.options pattern. This allows options to be passed at multiple stages, validated immediately, and automatically typed.

    Avoid these alternatives as they break the benefits of the modern-errors pattern:

    • Passing options as arguments to error methods.
    • Storing options directly on error properties.
    • Using top-level objects or external variables for configuration.
    • Using factory functions that return the plugin with pre-configured options.
    // RECOMMENDED PATTERN
    export default {
      name: 'example',
      getOptions: (options) => options,
      instanceMethods: {
        exampleMethod: (info) => {
          console.log(info.options.exampleOption)
        },
      },
    }
  5. How error subclasses and property inheritance work

    main

    When using subclass(), properties defined in the parent's props option are inherited by all descendants. This allows you to define shared metadata (like isError: true) at the top of your hierarchy.

    Subclasses can also define their own specific properties which will be merged with the parent's.

    export const BaseError = ModernError.subclass('BaseError', {
      props: { isError: true },
    })
    export const InputError = BaseError.subclass('InputError', {
      props: { isUserError: true },
    })
    
    const error = new InputError('...')
    console.log(error.isError) // true
    console.log(error.isUserError) // true
    console.log(error instanceof BaseError) // true
    console.log(error instanceof InputError) // true
  6. How plugins work in modern-errors

    main

    Plugins are plain objects with a default export used to extend error classes. They allow you to inject new functionality into the error lifecycle.

    Plugins can add:

    • Properties: Custom data on the error instance (e.g., error.myProp). Properties prefixed with _ (e.g., _secret) become non-enumerable and are hidden from iteration and logging.
    • Instance Methods: Methods available on the error instance (e.g., error.myMethod()) or via the class (e.g., ErrorClass.myMethod(error, ...args)).
    • Static Methods: Methods available directly on the error class (e.g., ErrorClass.myMethod(...args)).

    Note on Instance Methods: It is recommended to use the class-based invocation ErrorClass.methodName(error, ...args) rather than error.methodName(...args). This is because the class-based version automatically normalizes the error argument if it is an invalid error, whereas calling it directly on an unnormalized error will throw.

  7. Best practices for plugin state and architecture

    main

    When designing modern-errors plugins, follow these principles to ensure they are safe and reusable:

    • Avoid Global State: Do not modify global objects (like Error.prepareStackTrace()). This ensures your plugin can be used by other libraries without side effects.
    • Concurrency Safety: Do not store stateful objects (like class instances or network connections) in the global plugin state. This ensures plugins are safe for parallel async logic. Instead:
      • Provide methods that return these objects.
      • Require users to create the objects and pass them as arguments to plugin methods.
    • Separation of Concerns: If a plugin contains logic not specific to modern-errors, split it into a separate library. This keeps the plugin focused on integration and allows the core logic to be used independently.
  8. Narrow error types using instanceof

    main

    When catching exceptions, you can narrow the type of the caught error to a specific error class using the standard TypeScript instanceof operator. This allows you to safely access properties and methods specific to that error class.

    const InputError = BaseError.subclass('InputError', {
      props: { isUserError: true as const },
    })
    
    try {
      // ...
    } catch (error) {
      // Narrows `error` type to `InputError`
      if (error instanceof InputError) {
        const { isUserError } = error // Inferred type: `true`
      }
    }
  9. Install modern-errors

    main

    Install the core package via npm. If you use any plugins, you must install them separately as well.

    npm install modern-errors

    Requirements & Compatibility:

    • Node.js >= 18.18.0
    • Works in browsers
    • ES Module only: Must be loaded using import or import(). It does not support require().
    • TypeScript: Must be configured to output ES modules, not CommonJS.
  10. Handle and normalize unknown errors

    main

    In modern-errors, errors that are not explicitly caught and wrapped in a known error class are considered unknown (often indicating a bug). You can normalize these unexpected exceptions using BaseError.normalize(error, UnknownError) to ensure they follow the library's structure, apply plugins, and have a valid class.

    To ensure every error thrown from a module's main entry point is valid, wrap the top-level logic in a try/catch block and use normalize in the catch block.

    export const UnknownError = BaseError.subclass('UnknownError')
    
    try {
      return regExp.test(value)
    } catch (error) {
      // Now an `UnknownError` instance
      throw BaseError.normalize(error, UnknownError)
    }
  11. Configure plugin options with getOptions and isOptions

    main

    To support custom configuration, implement getOptions and isOptions in your plugin.

    getOptions(options, full)

    getOptions is called at multiple stages. The full boolean indicates if the options might still be partial:

    • full === false: Called when error classes are defined (ErrorClass.subclass(...)). Validation for required properties should be skipped.
    • full === true: Called when new errors are created (new ErrorClass(...)) or when calling methods with options as the last argument.

    If options are invalid, throw a standard Error. modern-errors will automatically prepend the message with Invalid "${plugin.name}" options: .

    isOptions(options)

    If your plugin methods take arguments, implement isOptions to help the engine distinguish between method arguments and plugin options passed as the last argument. This is required if you want to support passing options to instance or static methods.

    export default {
      name: 'example',
      // Determine if the last argument is the options object
      isOptions: (options) => typeof options === 'object' && options !== null,
    
      getOptions: (options, full) => {
        if (typeof options !== 'object' || options === null) {
          throw new Error('It must be a plain object.')
        }
    
        // Only validate required fields when 'full' is true
        if (full && options.apiKey === undefined) {
          throw new Error('"apiKey" is required.')
        }
    
        return options
      },
    }