@australiangreens/ag-error
v0.1.7
Published
Provides an AgError class to be used as part of a consistent error pattern across AG's backend and frontend js/ts apps.
Downloads
186
Keywords
Readme
ag-error
Provides an AgError class to be used as part of a consistent error pattern across AG's backend and frontend js/ts apps.
Specifically, exposes name
getter which will be informative regardless of code
minification or other renaming and hierachical
getter which will be the full
class path starting at AgError. For example
"AgError:SomeBaseAppError:FooApiError".
If using jest, it is recommended @australiangreens/ag-error-jest also be installed as a dev dependency to write tests for the subclasses.
Installation
With npm:
npm install @australiangreens/ag-error
With yarn:
yarn add @australiangreens/ag-error
Usage
Define your errors as extending the AgError superclass, with the requirement
that they all have a static errorName
property that matches the class name
and filename.
For example
// In SomeAppError.ts
import { AgError } from '@australiangreens/ag-error';
export class SomeBaseAppError extends AgError {
static errorName = 'SomeBaseAppError';
}
// In FooApiError.ts
import { SomeBaseAppError } from './SomeBaseAppError';
export class FooApiError extends SomeBaseAppError {
static errorName = 'FooApiError';
}
Then
import { SomeBaseAppError } from '../SomeBaseAppError';
import { FooApiError } from '../FooApiError';
const err1 = new SomeBaseAppError('Something bad');
const err2 = new FooApiError('Also bad');
console.log(err1.name); // => 'SomeBaseAppError'
console.log(err2.name); // => 'FooApiError'
console.log(err1.hierachicalName); // => 'AgError:SomeBaseAppError'
console.log(err1.hierachicalName); // => 'AgError:SomeBaseAppError:FooApiError'
This is useful when catching errors and using their name and hierachical name in error logs and in error messages/objects communicated between the API of different apps.
Extra data
Can have a subclass with extra data attached. This should be added as 2nd, 3rd etc arguments after the message. The first argument should always be maintained as an optional message (doesn't matter what the default is).
Note that if you add extra arguments, you will not be able use the custom
toBeValidAgErrorClass()
jest matcher (below).
Testing
If using jest and just want to get it working, skip to the next section
Ideally you should have unit tests for each subclass of AgError, confirming that:
- Objects of the class have the expected value for their name;
- Object of the class have the expected value for their hierarchicalName;
- The value of name is not dependent on the source class (i.e constructor.name) The motivation for the final requirement is to ensure the error information will work even if the code is minified or obsfucated.
The tests are primarily to just to mitigate the chance of typos. You may find them redundant depending on your workflow.
ag-error-jest
To make testing simpler, if using jest, a custom matcher
called toBeValidAgErrorClass()
is by the related ag-error-jest
. This will do
all the tests nececesary in one go, and is what ag-error
uses internally.
Installing ag-error-jest
With npm:
npm install --save-dev @australiangreens/ag-error-jest
With yarn:
yarn add --dev @australiangreens/ag-error-jest
Setup of ag-error-jest
Making use of the package once installs works in the same way as
jest-extended, relying on
side-effects. Add @australiangreens/ag-error-jest
to your jest
setupFilesAfterEnv
configuration property. See the jest
docs for details
For example in jest.config.js
:
export default {
//...
"setupFilesAfterEnv": ["@australiangreens/ag-error-jest"],
//...
}
Alternatively if you already have a ./src/setupTests.ts
in your
setupFilesAfterEnv
, then you and add an import into that intead:
import '@australiangreens/ag-error-jest';
Using ag-error-jest
So your test files for each error only needs to be something like:
import { BoilerplateError } from './BoilerplateError';
test('BoilerplateError meets requirements to be a valid AgError', () => {
expect(BoilerplateError).toBeValidAgErrorClass(
'BoilerplateError',
'AgError:BoilerplateError',
);
});
Caveat: Different constructor signature
If you add extra arguments to the constructor, the toBeValidAgErrorClass()
matcher may not work. In this case you should should be able to use
toBeValidAgErrorObject()
which checks the first two requirements, but will
need to manually test the first.
The following example shows a test for a special CiviApiAxiosError error that is a wrapper for axios related errors.
import { CiviApiAxiosError } from './CiviApiAxiosError';
import { createAxiosError } from '../../../utils/testing';
test('CiviApiAxiosError meets requirements to be a valid AgError', () => {
const err = new CiviApiAxiosError('some message', createAxiosError('Network Error', {}, null, {}, null));
expect(err).toBeValidAgErrorObject('CiviApiAxiosError', 'AgError:BoilerplateError:CiviApiError:CiviApiAxiosError');
// This is the bit we have to do manually.
// Not a normal sort of test, but basically want to ensure the way we've done
// this doesn't actually end up using the class name even if minified. We can
// (sort of) emulate this by deliberately breaking our own rules using a name
// that does not match the class to make sure the correct one is being used.
class DummySubClass extends CiviApiAxiosError {
static errorName = 'NotTheSame';
}
const dummy = new DummySubClass('some message', createAxiosError('Network Error', {}, null, {}, null));
expect(dummy.name).not.toEqual(dummy.constructor.name);
});
FAQ
Why not just use constructor.name
?
If AgError simply had this.name = this.constructor.name;
in its constructor,
there would be no need to separately define errorName for every class. However
that can't be relied on if any sort of build process is used that might change
class names (e.g. minifying the code).
You can still use (and are encouraged to use) instanceof
to check the actual
class, rather than the name or hierachicalName. The motivation for the names
is purely logging and debugging.
Why not use static name = '...'
property?
This results in typescript error TS2699. We could use @ts-ignore
however the
conversation in this issue
indicates this is a Bad Idea and there ended up being a
PR raised specifically to
cause this error - it is very much intended
Why not just use (non-static) name = '...'
property?
This is not feasible because it will prevent the hierarchicalName()
getter
method from working as intended - it would have to call a non-static method on
each class, necessitating a dummy object in every ‘layer’. This by itself would
be fine, but would mean all constructors of subclasses would have to guarantee
all arguments be optional, which isn’t possible to enforce.
It was simpler just to have a static property (i.e property on the constructor)
called errorName
. Then we have a simple name getter that just returns
this.constructor.errorName
and make use of this in the hierarchalName getter
too.
Admittedly this does still result in a typescript error about errorName not
existing on the constructor, despite it clearly being there, due to an
issue that has been open
since 2015, whose workarounds don't help us. It doesn't look like a simple issue
to fix. So in this case we do actually use @ts-ignore
knowing the for certain
exact the constructor does in fact have the property in question.
About the build process
Originally the plan was to only compile with ESM as the target, since that is what we are using across all our apps. However due to Jest not yet properly handling ESM, we need to provide the commonjs files too.
To achive this, we use the hybrid module pattern described here: https://www.sensedeep.com/blog/posts/2021/how-to-create-single-source-npm-module.html
We make a few changes however:
- For the directory in dist we use "esm" instead of "mjs"
- Rather than using a separate
tsconfig-base.json
, justtsconfig.json
contain the ESM definition and havetsconfig-cjs.son
extend and override it. - In
tsconfig-cjs.json
, adddeclaration": false
.
The reasoning for last point is that we don't need the declaration files in both places; they are basically just for editor hinting. the handbook states "The .d.ts syntax intentionally looks like ES Modules syntax", so it makes more sense to put the declarations in the esm directory.