Merge pull request #2 from overleaf/es6

Add support for errors based on ES6 classes
This commit is contained in:
John Lees-Miller 2019-07-01 11:48:03 +01:00 committed by GitHub
commit 5bf235c549
8 changed files with 1420 additions and 186 deletions

View file

@ -1,12 +1,104 @@
# overleaf-error-type
Make custom error types that pass `instanceof` checks, have stack traces and support custom messages and properties.
Make custom error types that:
- pass `instanceof` checks,
- have stack traces,
- support custom messages and properties (`info`), and
- can wrap internal errors (causes) like [VError](https://github.com/joyent/node-verror).
## For ES6
ES6 classes make it easy to define custom errors by subclassing `Error`. Subclassing `errorType.Error` adds a few extra helpers.
### Usage
#### Throw an error directly
```js
const errorType = require('overleaf-error-type')
function doSomethingBad () {
throw new errorType.Error({
message: 'did something bad',
info: { thing: 'foo' }
})
}
doSomethingBad()
// =>
// { ErrorTypeError: did something bad
// at doSomethingBad (repl:2:9) <-- stack trace
// name: 'ErrorTypeError', <-- default name
// info: { thing: 'foo' } } <-- attached info
```
#### Custom error class
```js
class FooError extends errorType.Error {
constructor (options) {
super({ message: 'failed to foo', ...options })
}
}
function doFoo () {
throw new FooError({ info: { foo: 'bar' } })
}
doFoo()
// =>
// { FooError: failed to foo
// at doFoo (repl:2:9) <-- stack trace
// name: 'FooError', <-- correct name
// info: { foo: 'bar' } } <-- attached info
```
#### Wrapping an inner error (cause)
```js
function doFoo2 () {
try {
throw new Error('bad')
} catch (err) {
throw new FooError({ info: { foo: 'bar' } }).withCause(err)
}
}
doFoo2()
// =>
// { FooError: failed to foo: bad <-- combined message
// at doFoo2 (repl:5:11) <-- stack trace
// name: 'FooError', <-- correct name
// info: { foo: 'bar' }, <-- attached info
// cause: <-- the cause (inner error)
// Error: bad <-- inner error message
// at doFoo2 (repl:3:11) <-- inner error stack trace
// at repl:1:1
// ...
try {
doFoo2()
} catch (err) {
console.log(errorType.getFullStack(err))
}
// =>
// FooError: failed to foo: bad
// at doFoo2 (repl:5:11)
// at repl:2:3
// ...
// caused by: Error: bad
// at doFoo2 (repl:3:11)
// at repl:2:3
// ...
```
## For ES5
For backward compatibility, the following ES5-only interface is still supported.
The approach is based mainly on https://gist.github.com/justmoon/15511f92e5216fa2624b; it just tries to DRY it up a bit.
## Usage
### Usage
### Define a standalone error class
#### Define a standalone error class
```js
const errorType = require('overleaf-error-type')
@ -22,7 +114,7 @@ doSomethingBad()
// at doSomethingBad (repl:2:9) <-- stack trace
```
### Define an error subclass
#### Define an error subclass
```js
const SubCustomError = errorType.extend(CustomError, 'SubCustomError')
@ -37,7 +129,7 @@ try {
}
```
### Add custom message and/or properties
#### Add custom message and/or properties
```js
const UserNotFoundError = errorType.define('UserNotFoundError',
@ -50,7 +142,7 @@ throw new UserNotFoundError(123)
// => UserNotFoundError: User not found: 123
```
### Add custom Error types under an existing class
#### Add custom Error types under an existing class
```js
class User {
@ -72,5 +164,17 @@ User.lookup(123)
## References
General:
- [MDN: Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error)
- [Error Handling in Node.js](https://www.joyent.com/node-js/production/design/errors)
- [verror](https://github.com/joyent/node-verror)
For ES6:
- [Custom JavaScript Errors in ES6](https://medium.com/@xjamundx/custom-javascript-errors-in-es6-aa891b173f87)
- [Custom errors, extending Error](https://javascript.info/custom-errors)
For ES5:
- https://gist.github.com/justmoon/15511f92e5216fa2624b
- [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error)

View file

@ -3,11 +3,119 @@
var util = require('util')
/**
* Make custom error types that pass `instanceof` checks, have stack traces and
* support custom messages and properties.
* Make custom error types that pass `instanceof` checks, have stack traces,
* support custom messages and properties, and support wrapping errors (causes).
*
* @module
*/
//
// For ES6+ Classes
//
/**
* A base class for custom errors that handles:
*
* 1. Wrapping an optional 'cause'.
* 2. Storing an 'info' object with additional data.
* 3. Setting the name to the subclass name.
*
* @extends Error
*/
class ErrorTypeError extends Error {
/**
* @param {string} message as for built-in Error
* @param {?object} info extra data to attach to the error
*/
constructor ({ message, info }) {
super(message)
this.name = this.constructor.name
this.info = info
}
/**
* Wrap the given error, which caused this error.
*
* @param {Error} cause
* @return {this}
*/
withCause (cause) {
this.cause = cause
if (this.message && cause.message) {
this.message += ': ' + cause.message
}
return this
}
}
/**
* Base class for errors with a corresponding HTTP status code.
*
* @extends ErrorTypeError
*/
class ErrorWithStatusCode extends ErrorTypeError {
/**
* @param {?number} statusCode an HTTP status code
* @param {object} options as for ErrorTypeError
*/
constructor ({ statusCode, ...options }) {
super(options)
this.statusCode = statusCode || 500
}
}
exports.ErrorWithStatusCode = ErrorWithStatusCode
/**
* Return the `info` property from `error` and recursively merge the `info`
* properties from the error's causes, if any.
*
* If a property is repeated, the first one in the cause chain wins.
*
* @param {?Error} error assumed not to have circular causes
* @return {Object}
*/
function getFullInfo (error) {
if (!error) return {}
const info = getFullInfo(error.cause)
if (typeof error.info === 'object') Object.assign(info, error.info)
return info
}
/**
* Return the `stack` property from `error` and recursively append the `stack`
* properties from the error's causes, if any.
*
* @param {?Error} error assumed not to have circular causes
* @return {string}
*/
function getFullStack (error) {
if (!error) return ''
const causeStack = getFullStack(error.cause)
if (causeStack) return (error.stack + '\ncaused by: ' + causeStack)
return error.stack
}
/**
* Is `error` or one of its causes an instance of `klass`?
*
* @param {?Error} error assumed not to have circular causes
* @param {function} klass
* @return {Boolean}
*/
function hasCauseInstanceOf (error, klass) {
if (!error) return false
return error instanceof klass || hasCauseInstanceOf(error.cause, klass)
}
exports.Error = ErrorTypeError
exports.getFullInfo = getFullInfo
exports.getFullStack = getFullStack
exports.hasCauseInstanceOf = hasCauseInstanceOf
//
// For ES5
//
function extendErrorType (base, name, builder) {
var errorConstructor = function () {
Error.captureStackTrace && Error.captureStackTrace(this, this.constructor)

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,6 @@
"private": true,
"devDependencies": {
"chai": "^3.3.0",
"mocha": "^2.3.3"
"mocha": "^6.1.4"
}
}

View file

@ -0,0 +1,123 @@
const errorType = require('..')
const { expectError } = require('./support')
class CustomError1 extends errorType.Error {
constructor (options) {
super({ message: 'failed to foo', ...options })
}
}
class CustomError2 extends errorType.Error {
constructor (options) {
super({ message: 'failed to bar', ...options })
}
}
describe('errorType.Error', () => {
it('handles a custom error type with a cause', () => {
function doSomethingBadInternally () {
throw new Error('internal error')
}
function doSomethingBad () {
try {
doSomethingBadInternally()
} catch (err) {
throw new CustomError1({ info: { userId: 123 } }).withCause(err)
}
}
try {
doSomethingBad()
expect.fail('should have thrown')
} catch (e) {
expectError(e, {
name: 'CustomError1',
klass: CustomError1,
message: 'CustomError1: failed to foo: internal error',
firstFrameRx: /doSomethingBad/
})
expect(errorType.getFullInfo(e)).to.deep.equal({ userId: 123 })
const fullStack = errorType.getFullStack(e)
expect(fullStack).to.match(
/^CustomError1: failed to foo: internal error$/m
)
expect(fullStack).to.match(
/^caused by: Error: internal error$/m
)
}
})
it('handles a custom error type with nested causes', () => {
function doSomethingBadInternally () {
throw new Error('internal error')
}
function doBar () {
try {
doSomethingBadInternally()
} catch (err) {
throw new CustomError2({ info: { database: 'a' } }).withCause(err)
}
}
function doFoo () {
try {
doBar()
} catch (err) {
throw new CustomError1({ info: { userId: 123 } }).withCause(err)
}
}
try {
doFoo()
expect.fail('should have thrown')
} catch (e) {
expectError(e, {
name: 'CustomError1',
klass: CustomError1,
message: 'CustomError1: failed to foo: failed to bar: internal error',
firstFrameRx: /doFoo/
})
expect(errorType.getFullInfo(e)).to.deep.equal({
userId: 123,
database: 'a'
})
const fullStack = errorType.getFullStack(e)
expect(fullStack).to.match(
/^CustomError1: failed to foo: failed to bar: internal error$/m
)
expect(fullStack).to.match(
/^caused by: CustomError2: failed to bar: internal error$/m
)
expect(fullStack).to.match(
/^caused by: Error: internal error$/m
)
}
})
})
describe('errorType.ErrorWithStatusCode', () => {
it('accepts a status code', () => {
function findPage () {
throw new errorType.ErrorWithStatusCode({
message: 'page not found',
info: { url: '/foo' },
statusCode: 404
})
}
try {
findPage()
} catch (e) {
expectError(e, {
name: 'ErrorWithStatusCode',
klass: errorType.ErrorWithStatusCode,
message: 'ErrorWithStatusCode: page not found',
firstFrameRx: /findPage/
})
expect(e.statusCode).to.equal(404)
expect(e.info).to.deep.equal({url: '/foo'})
}
})
})

View file

@ -0,0 +1,83 @@
const { getFullInfo, getFullStack, hasCauseInstanceOf } = require('..')
describe('errorType.getFullInfo', () => {
it('works on a normal error', () => {
const err = new Error('foo')
expect(getFullInfo(err)).to.deep.equal({ })
})
it('works on an error with .info', () => {
const err = new Error('foo')
err.info = { userId: 123 }
expect(getFullInfo(err)).to.deep.equal({ userId: 123 })
})
it('merges info from a cause chain', () => {
const err1 = new Error('foo')
const err2 = new Error('bar')
err1.cause = err2
err2.info = { userId: 123 }
expect(getFullInfo(err1)).to.deep.equal({ userId: 123 })
})
it('merges info from a cause chain with no info', () => {
const err1 = new Error('foo')
const err2 = new Error('bar')
err1.cause = err2
expect(getFullInfo(err1)).to.deep.equal({})
})
it('merges info from a cause chain with duplicate keys', () => {
const err1 = new Error('foo')
const err2 = new Error('bar')
err1.cause = err2
err1.info = { userId: 123 }
err2.info = { userId: 456 }
expect(getFullInfo(err1)).to.deep.equal({ userId: 123 })
})
it('works on an error with .info set to a string', () => {
const err = new Error('foo')
err.info = 'test'
expect(getFullInfo(err)).to.deep.equal({})
})
})
describe('errorType.getFullStack', () => {
it('works on a normal error', () => {
const err = new Error('foo')
const fullStack = getFullStack(err)
expect(fullStack).to.match(/^Error: foo$/m)
expect(fullStack).to.match(/^\s+at /m)
})
it('works on an error with a cause', () => {
const err1 = new Error('foo')
const err2 = new Error('bar')
err1.cause = err2
const fullStack = getFullStack(err1)
expect(fullStack).to.match(/^Error: foo$/m)
expect(fullStack).to.match(/^\s+at /m)
expect(fullStack).to.match(/^caused by: Error: bar$/m)
})
})
describe('errorType.hasCauseInstanceOf', () => {
it('works on a normal error', () => {
const err = new Error('foo')
expect(hasCauseInstanceOf(null, Error)).to.be.false
expect(hasCauseInstanceOf(err, Error)).to.be.true
expect(hasCauseInstanceOf(err, RangeError)).to.be.false
})
it('works on an error with a cause', () => {
const err1 = new Error('foo')
const err2 = new RangeError('bar')
err1.cause = err2
expect(hasCauseInstanceOf(err1, Error)).to.be.true
expect(hasCauseInstanceOf(err1, RangeError)).to.be.true
expect(hasCauseInstanceOf(err1, TypeError)).to.be.false
})
})

View file

@ -1,6 +1,7 @@
'use strict'
var errorType = require('..')
const { expectError } = require('./support')
describe('errorType', function () {
it('defines a custom error type', function () {
@ -14,29 +15,12 @@ describe('errorType', function () {
doSomethingBad()
expect.fail('should have thrown')
} catch (e) {
// should set the name to the error's name
expect(e.name).to.equal('CustomError')
// should be an instance of the error type
expect(e instanceof CustomError).to.be.true
// should be an instance of the built-in Error type
expect(e instanceof Error).to.be.true
// should be recognised by util.isError
expect(require('util').isError(e)).to.be.true
// should have a stack trace
expect(e.stack).to.be.truthy
// toString should return the default error message formatting
expect(e.toString()).to.equal('CustomError')
// stack should start with the default error message formatting
expect(e.stack.split('\n')[0], 'CustomError')
// first stack frame should be the function where the error was thrown
expect(e.stack.split('\n')[1]).to.match(/doSomethingBad/)
expectError(e, {
name: 'CustomError',
klass: CustomError,
message: 'CustomError',
firstFrameRx: /doSomethingBad/
})
}
})

View file

@ -3,3 +3,29 @@
var chai = require('chai')
global.expect = chai.expect
exports.expectError = function errorTypeExpectError (e, expected) {
// should set the name to the error's name
expect(e.name).to.equal(expected.name)
// should be an instance of the error type
expect(e instanceof expected.klass).to.be.true
// should be an instance of the built-in Error type
expect(e instanceof Error).to.be.true
// should be recognised by util.isError
expect(require('util').isError(e)).to.be.true
// should have a stack trace
expect(e.stack).to.be.truthy
// toString should return the default error message formatting
expect(e.toString()).to.equal(expected.message)
// stack should start with the default error message formatting
expect(e.stack.split('\n')[0], expected.name)
// first stack frame should be the function where the error was thrown
expect(e.stack.split('\n')[1]).to.match(expected.firstFrameRx)
}