This library is a battle-tested attempt to make creating custom errors easier. How many times you wanted to return i.e.: http status code with an error?
Something like that surely works:
const error = new Error("Cannot do stuff");
error.statusCode = 400;
throw error;
But it's not very readable, you can't easily distinguish it from a regular error, and it's not type safe.
What if you could do something like that?
throw new APIError("Cannot do stuff", { statusCode: 400 });
@ezez/errors
allows you to do just that!
I propose to create a file with all your custom errors. You can later import them from there.
To create a new error class use exported createError
function.
The only argument is the error name, accessible later on name
class property and all instances.
// file: errors.ts
import { createError } from "@ezez/errors";
export const DatabaseError = createError("DatabaseError");
export const QueryError = DatabaseError.extend("QueryError"); // extend custom error
export const ApiParametersError = createError("ApiParametersError", TypeError); // extend built-in error
We just created:
We used two available syntaxes for creating new error classes:
createError(name, TypeError)
- use this one if you want to extend any native error (like TypeError
, SyntaxError
etc.)SomeError.extend(name)
- use this one if you want to extend your custom errorNote: Without TypeScript createError can be used for extending custom errors too, but it's recommended to still use these methods as described above.
Throw your errors like you would throw any other error:
throw new DatabaseError("Connection lost");
Or feel the power of @ezez/errors
and throw errors with extra data:
throw new DatabaseError("Connection lost", { date: Date.now(), uptime: process.uptime() });
throw new QueryError("Query failed", { query: "SELECT * FROM users", date: Date.now(), queryTime: 1234 });
First, a humble reminder - in JavaScript anything can be thrown. It can be built-in errors like TypeError
or just
Error
, custom errors like these created with @ezez/errors
or even plain objects or primitives.
While it's an evil practice to throw 100
- it's still possible, so always typecheck your errors before using them.
try {
throw QueryError("Query failed", { query: "SELECT * FROM users" });
}
catch (e) {
if (e instanceof QueryError) {
console.error(e.message, e.details?.query);
}
else if (e instanceof Error) {
console.error(e.message);
}
else {
console.error("Not an error type", e);
}
}
Important reminder:
instanceof
will check if given value is an instance of given type or any of its ancestors. This means if you do
multi-level extends with your classes - always check for the most specific type first, then for the less specific
(this is also why we used else
in this example). You can always check the name
property of the error as well.
try {
throw DatabaseError("Connection failed");
}
catch (e) {
if (e instanceof QueryError) {
// this is true, because QueryError is a subclass of DatabaseError
console.error(e.name); // "DatabaseError"
}
if (e instanceof Error) {
// this is also true, we are missing `else` so this part of code will execute as well
console.error(e.message);
}
}
If you don't use TypeScript, you can skip this section.
When creating an error class with createError
function, you can pass a generic type that defines the type of extra
data you may want to throw with your error. Omitting it will allow any data to be passed.
const QueryError = createError<{ query: string }>("QueryError");
try {
throw new QueryError("Query failed", { query: "SELECT * FROM users" });
}
catch (e) {
if (e instanceof QueryError) {
console.error(e.message, e.details?.query);
}
}
e.details?.
has intellisense support, so you can easily see what data is available and work with it.
Why optional chaining then? details
property is always optional, even if you specify the shape of extra data - you
don't have to pass anything, but you can pass data that matches the shape.
Note: When extending a class that defines a shape of extra data, your subclass will inherit that shape. If you want you can provide a subtype of the shape, ie: add additional properties to it.
This is to provide extra type safety. See
Important reminder
ofCatching errors
section.
Instead of doing typechecks if something caught is not an error you can normalize any value to be an error. Every error
class created with createError
has a static method normalize
you can use to achieve that.
const BaseError = createError("BaseError");
try {
throw 5;
}
catch (e) {
const error = BaseError.normalize(e);
// you can now be sure that `error` is an instance of `BaseError` or any of native errors.
console.error(error.message);
}
Normalizing can be done with three modes:
instanceof
- it will NOT normalize if given value is instance of Error
strict
- it will NOT normalize if given value looks very close to an Error
instance, but it doesn't do
instanceof
check - instead it checks if given value has name
, message
, stack
properties, that are
non-enumerable strings, and name
is ending with Error
. This is the default mode. It should support most node errors
that doesn't follow the "thrown values have to be instanceof
Error" practice.loose
- it will NOT normalize anything that has name
, message
and stack
properties. Use it if you work with a
lot of bad error lookalikes, but for some reason you want to keep them.Sometimes you want to throw an error that is based on another error. For example, you want to throw a RenderError
but you want to keep the original error message and stack trace. You can do that!
const RenderError = createError("RenderError");
const renderHTML = () => {
try {
return render();
}
catch (originalError) {
// Make sure to ensure that `originalError` is an instance of `Error`!
// See `Normalizing errors` section for more info.
throw new RenderError("Failed to render HTML", { date: Date.now() }, originalError);
}
}
try {
renderHTML()
}
catch (e) {
if (e instanceof RenderError) {
console.error(e.message, e.details?.date); // RenderError: Failed to render HTML, 123456789
console.error(e.parent?.message); // i.e.: Error: cannot read property `foo` of undefined
console.error(e.ancestors); // array of [parent, parent of that parent, ...until there is no parent]
}
}
When creating instance of your error it expects three values:
{string}
- a message{object}
- error details{Error}
- parent errorWhat's important - you can even skip some of them and provide null/undefined instead.
Note: If you pass some argument type twice (like two strings) - you will instead get a
TypeError
thrown.
Note: If you skip error message, but provide parent error - the message will be copied from the parent error.
names
propertynames
property is an array of strings that describe the hierarchy of errors. It can be useful to understand what
happened if you log your errors and review them later.
Let's take a look at the example:
[
"DatabaseError",
["TypeError", "Error"],
"InternalServerError",
"Error",
]
This means that DatabaseError
was instantiated with TypeError
error as argument. TypeError
inherits from Error
.
And DatabaseError
inherits from InternalServerError
which inherits from Error
.
This array is safe to be stringified for logging purposes, it will look like this when stringified:
"(DatabaseError,(TypeError,Error),InternalServerError,Error)"
Now let's take a look at more complex example:
const NotFoundError = createError("NotFoundError");
const InternalServerError = createError("InternalServerError");
const DatabaseError = InternalServerError.extend("DatabaseError")
const RenderError = InternalServerError.extend("RenderError");
const UnknownError = InternalServerError.extend("UnknownError");
// A database operations handler example
const db = { query: (query) => {
// Some implementation, let's assume it throws an error
throw new DatabaseError("Database query failed", { query });
}};
// Function that gets a product from database
const getProduct = id => {
if (!id) {
throw new TypeError("Product id is missing");
}
const data = db.query("SELECT * FROM products WHERE id = ?", id);
if (!data) {
throw new NotFoundError("Product cannot be found", { id });
}
return data;
};
// Function that renders a product page using given data
const renderProduct = (product) => {
// Some implementation, let's assume it throws an error
throw new RenderError("Failed to render product page");
};
// Function that fetches all data needed and renders a product page
const renderProductPage = (id) => {
try {
const product = getProduct(id);
return renderProduct(product);
}
catch (error) {
if (error instanceof TypeError) {
// Change error type, adding more details and keeping parent error message and stack trace
throw new InternalServerError("Failed to get product", { id }, error);
}
throw error; // rethrow error otherwise
}
}
// Main app operation
try {
const html = renderProductPage(req.GET.id);
res.send(html);
}
catch (e) {
// Making sure we always work with errors
// If we caught something that is not an error, we will create an instance of UnknownError
// Otherwise it will be still the same value as `e`
const normalizedError = UnknownError.normalize(e);
// Let's log the error to some service
loggingService.log(
normalizedError.name, normalizedError.message, normalizedError.details, String(e.names), normalizedError.stack
);
// Use error instance type to determine return code
if (e instanceof NotFoundError) {
res.send(router.renderError(404));
}
else {
res.send(router.renderError(500));
}
}
Generated using TypeDoc