Here’s a very simple and familiar way to implement errors in your JSON API without wracking your brain too hard, in two steps:
- Return a
4xx
HTTP status code. - Return a JSON object with a
name
and amessage
property.
No top-level { "error": {} }
or { "errors": [] }
necessary.
Example response:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{ "name": "Error", "message": "Invalid request" }
The name
property is the name of your error and looks like a class name, eg.
Error
, RangeError
or TypeError
and message
is a human-readable string
describing the error. This convention matches what JavaScript
uses
which makes sense for a JSON API as well. Given this, we can turn an error
response into a proper JavaScript Error
like so:
// api.js
// Our error class with a default name
export class APIError extends Error {}
APIError.prototype.name = 'APIError';
function getError(url, params, res) {
const { name, message, ...info } = res;
const error = new APIError(message);
// Set the error's name from the response
error.name = name;
// Copy any additional properties
Object.assign(error, info);
// Add properties for convenience
error.params = params;
error.url = url;
return error;
}
// Our API request wrapper
export async function post(url, params) {
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(params)
});
const res = await response.json();
if (response.ok)
return res;
throw getError(url, params, res);
}
And this is how to check for errors:
import * as api from './api';
async function main() {
try {
await api.post('/things', { name: 'My thing' });
} catch (e) {
// Rethrow non-API errors
if (!(e instanceof api.APIError))
throw e;
// Handle API errors
console.error(`${e.name} at ${e.url}`);
if (e.name == 'FatalError')
console.error('This is fine.')
}
}
main();
You now have nicer error handling with descriptive names and stack traces.
Error Classes
This can be taken one step further by using separate classes for each error
type, which can be less error-prone than checking name
:
// api.js
const errors = {};
function addErrorClass(name, base = Error) {
const CustomError = class extends base {};
CustomError.prototype.name = name;
// Prevent the inevitable copy-paste bug
if (name in errors)
throw new Error(`Error class ${name} already exists`);
return (errors[name] = CustomError);
}
// API Errors
const _Error = addErrorClass('Error');
export { _Error as Error };
export const FieldError = addErrorClass('FieldError', _Error);
function getError(url, params, res) {
const { name, message, ...info } = res;
// Instantiate the appropriate error class
const error = new (errors[name] || errors['Error'])(message);
// Set the name anyway if we don't know about this error
if (!errors[name])
error.name = name;
// Copy any additional properties
Object.assign(error, info);
// Add properties for convenience
error.params = params;
error.url = url;
return error;
}
This exports two separate error types - api.Error
, the base class for all API
errors and api.FieldError
, an error type for invalid data in form fields
which will have an additional field
property.
You can now use the API like so:
import * as api from './api';
async function main() {
try {
await api.post('/things', { name: 'My thing' });
} catch (e) {
if (!(e instanceof api.Error))
throw e;
console.error(`${e.name} at ${e.url}`);
if (e instanceof api.FieldError)
alert(`Invalid field "${e.field}" - ${e.message}`);
else
console.error(e.message);
}
}
main();
You can easily add additional properties, such as the common code
. You can
also have nested errors, eg. FormError
with an errors
property which is an
array of FieldError
.