Skip to content

Latest commit

 

History

History
613 lines (476 loc) · 14.8 KB

plugins.md

File metadata and controls

613 lines (476 loc) · 14.8 KB

Creating a plugin

This document explains how to create a plugin for modern-errors. To learn how to install, use and configure plugins, please refer to the main documentation instead.

Features

Plugins can add error:

Examples

Simple example

export default {
  name: 'secret',
  properties({ error }) {
    return { message: error.message.replaceAll('secret', '******') }
  },
}
import ModernError from 'modern-errors'

import secretPlugin from './secret.js'

const BaseError = ModernError.subclass('BaseError', { plugins: [secretPlugin] })
const error = new BaseError('Message with a secret')
console.log(error.message) // 'Message with a ******'

Real-life examples

Existing plugins can be used for inspiration.

Template

The following directory contains a template to start a new plugin, including types and tests.

API

Plugins are plain objects with a default export. All members are optional except for name.

export default {
  // Name used to configure the plugin
  name: 'example',

  // Set error properties
  properties(info) {
    return {}
  },

  // Add error instance methods like `error.exampleMethod(...args)`
  instanceMethods: {
    exampleMethod(info, ...args) {
      // ...
    },
  },

  // Add `ErrorClass` static methods like `ErrorClass.staticMethod(...args)`
  staticMethods: {
    staticMethod(info, ...args) {
      // ...
    },
  },

  // Validate and normalize options
  getOptions(options, full) {
    return options
  },

  // Determine if a value is plugin's options
  isOptions(options) {
    return typeof options === 'boolean'
  },
}

name

Type: string

Plugin's name. It is used to configure the plugin's options.

Only lowercase letters must be used (as opposed to _ - . or uppercase letters).

// Users configure this plugin using
// `ErrorClass.subclass('ErrorName', { example: ... })`
// or `new ErrorClass('...', { example: ... })
export default {
  name: 'example',
}

properties

Type: (info) => object

Set properties on error.* (including message or stack). The properties to set must be returned as an object.

export default {
  name: 'example',
  // Sets `error.example: true`
  properties() {
    return { example: true }
  },
}

instanceMethods.{methodName}

Type: (info, ...args) => any

Add error instance methods like error.methodName(...args).

The first argument info is provided by modern-errors. The other ...args are forwarded from the method's call.

If the logic involves an error instance or error-specific options, instance methods should be preferred over static methods. Otherwise, static methods should be used.

export default {
  name: 'example',
  // `error.concatMessage("one")` returns `${error.message} - one`
  instanceMethods: {
    concatMessage({ error }, string) {
      return `${error.message} - ${string}`
    },
  },
}

staticMethods.{methodName}

Type: (info, ...args) => any

Add error static methods like ErrorClass.methodName(...args).

The first argument info is provided by modern-errors. The other ...args are forwarded from the method's call.

export default {
  name: 'example',
  // `ErrorClass.multiply(2, 3)` returns `6`
  staticMethods: {
    multiply(info, first, second) {
      return first * second
    },
  },
}

getOptions

Type: (options, full) => options

Normalize and return the plugin's options. Required to use plugin options.

If options are invalid, an Error should be thrown. The error message is automatically prepended with Invalid "${plugin.name}" options:. Regular Errors should be thrown, as opposed to using modern-errors itself.

The plugin's options's type can be anything.

export default {
  name: 'example',
  getOptions(options = true) {
    if (typeof options !== 'boolean') {
      throw new Error('It must be true or false.')
    }

    return options
  },
}

full

Plugin users can pass additional options at multiple stages. Each stage calls getOptions().

full is a boolean parameter indicating whether the options might still be partial. It is false in the first stage above, true in the others.

When full is false, any logic validating required properties should be skipped. The same applies to properties depending on each other.

export default {
  name: 'example',
  getOptions(options, full) {
    if (typeof options !== 'object' || options === null) {
      throw new Error('It must be a plain object.')
    }

    if (full && options.apiKey === undefined) {
      throw new Error('"apiKey" is required.')
    }

    return options
  },
}

isOptions

Type: (options) => boolean

Plugin users can pass the plugin's options as the last argument of any plugin method (instance or static). isOptions() determines whether the last argument of a plugin method are options or not. This should be defined if the plugin has any method with arguments.

If options are invalid but can be determined not to be the last argument of any plugin's method, isOptions() should still return true. This allows getOptions() to validate them and throw proper error messages.

// `error.exampleMethod('one', true)` results in:
//   options: true
//   args: ['one']
// `error.exampleMethod('one', 'two')` results in:
//   options: undefined
//   args: ['one', 'two']
export default {
  name: 'example',
  isOptions(options) {
    return typeof options === 'boolean'
  },
  getOptions(options) {
    return options
  },
  instanceMethod: {
    exampleMethod({ options }, ...args) {
      // ...
    },
  },
}

info

info is a plain object passed as the first argument to properties(), instance methods and static methods.

Its members are readonly and should not be directly mutated. Exception: instance methods can mutate info.error.

error

Type: Error

Normalized error instance. This is not defined in static methods.

export default {
  name: 'example',
  properties({ error }) {
    return { isInputError: error.name === 'InputError' }
  },
}

ErrorClass

Type: ErrorClass

Current error class.

export default {
  name: 'example',
  instanceMethods: {
    addErrors({ error, ErrorClass }, errors = []) {
      error.errors = errors.map(ErrorClass.normalize)
    },
  },
}

ErrorClasses

Type: ErrorClass[]

Array containing both the current error class and all its subclasses (including deep ones).

export default {
  name: 'example',
  staticMethods: {
    isKnownErrorClass({ ErrorClasses }, value) {
      return ErrorClasses.includes(value)
    },
  },
}

options

Type: any

Plugin's options, as returned by getOptions().

export default {
  name: 'example',
  getOptions(options) {
    return options
  },
  // `new ErrorClass('message', { example: value })` sets `error.example: value`
  properties({ options }) {
    return { example: options }
  },
}

errorInfo

Type: (Error) => info

Returns the info object from a specific Error. All members are present except for info.errorInfo itself.

export default {
  name: 'example',
  staticMethods: {
    getLogErrors({ errorInfo }) {
      return function logErrors(errors) {
        errors.forEach((error) => {
          const { options } = errorInfo(error)
          console.error(options.example?.stack ? error.stack : error.message)
        })
      }
    },
  },
}

TypeScript

Any plugin's types are automatically exposed to its TypeScript users.

getOptions

The types of getOptions()'s parameters are used to validate the plugin's options.

// Any `{ example }` plugin option passed by users will be validated as boolean
export default {
  name: 'example' as const,
  getOptions(options: boolean): object {
    // ...
  },
}

name

The name property should be typed as const so it can be used to validate the plugin's options.

export default {
  name: 'example' as const,
  // ...
}

properties, instanceMethods and staticMethods

The types of properties(), instanceMethods and staticMethods are also exposed to plugin users. Please note generics are currently ignored.

// Any `error.exampleMethod(input)` call will be validated
export default {
  // ...
  instanceMethods: {
    exampleMethod(info: Info['instanceMethods'], input: boolean): void {},
  },
}

Info

The info parameter can be typed with Info['properties'], Info['instanceMethods'], Info['staticMethods'] or Info['errorInfo'].

import type { Info } from 'modern-errors'

export default {
  // ...
  properties(info: Info['properties']) {
    // ...
  },
}

Plugin

A Plugin type is available to validate the plugin's shape. satisfies Plugin should be used (as opposed to const plugin: Plugin = { ... }) to prevent widening it and removing any specific types declared by that plugin.

import type { Plugin } from 'modern-errors'

export default {
  // ...
} satisfies Plugin

Publishing

If the plugin is published on npm, we recommend the following conventions:

  • The npm package name should be [@scope/]modern-errors-${plugin.name}
  • The repository name should match the npm package name
  • "modern-errors" and "modern-errors-plugin" should be added as both package.json keywords and GitHub topics
  • "modern-errors" should be added in the package.json's peerDependencies, not in the production dependencies, devDependencies nor bundledDependencies. Its semver range should start with ^. Also, peerDependenciesMeta.modern-errors.optional should not be used.
  • The README should document how to:
    • Add the plugin to modern-errors (example)
    • Configure options, if there are any (example)
  • The plugin should export its types for TypeScript users
  • Please create an issue on the modern-errors repository so we can add the plugin to the list of available ones! 🎉

Best practices

Options

Serializable options

Options types should ideally be JSON-serializable. This allows preserving them when errors are serialized/parsed. In particular, functions and class instances should be avoided in plugin options, when possible.

Separate options

modern-errors provides with a pattern for options that enables them to be:

export default {
  name: 'example',
  getOptions(options) {
    return options
  },
  instanceMethods: {
    exampleMethod(info) {
      console.log(info.options.exampleOption)
    },
  },
}

Plugins should avoid alternatives since they would lose those benefits. This includes:

  • Error method arguments, for any configuration option
export default {
  name: 'example',
  instanceMethods: {
    exampleMethod(exampleOption) {
      console.log(exampleOption)
    },
  },
}
  • Error properties
export default {
  name: 'example',
  instanceMethods: {
    exampleMethod(info) {
      console.log(info.error.exampleOption)
    },
  },
}
  • Top-level objects
export const pluginOptions = {}

export default {
  name: 'example',
  instanceMethods: {
    exampleMethod() {
      console.log(pluginOptions.exampleOption)
    },
  },
}
  • Functions taking options as input and returning the plugin
export default function getPlugin(exampleOption) {
  return {
    name: 'example',
    instanceMethods: {
      exampleMethod() {
        console.log(exampleOption)
      },
    },
  }
}

State

Global state

Plugins should be usable by libraries. Therefore, modifying global objects (such as Error.prepareStackTrace()) should be avoided.

Error-specific state

WeakMaps should be used to keep error-specific internal state, as opposed to using error properties (even with symbol keys). This ensures those properties are not exposed to users not printed or serialized.

const state = new WeakMap()

export default {
  name: 'example',
  instanceMethods: {
    exampleMethod({ error }) {
      state.set(error, { example: true })
    },
  },
}

State objects

Other state objects, such as class instances or network connections, should not be kept in the global state. This ensures plugins are concurrency-safe, i.e. can be safely used in parallel async logic. Instead, plugins should either:

  • Provide with methods returning such objects
  • Let users create those objects and pass them as arguments to plugin methods