Error Handling
Thrown errors are a good thing! They mean the runtime has successfully identified when something in your program has gone wrong and it's letting you know by stopping function execution on the current stack, killing the process (in Node), and notifying you in the console with a stack trace.
Always use Error for throwing or rejecting
JavaScript as well as TypeScript allow you to throw
any object. A Promise can also be rejected with any reason object.
It is advisable to use the throw
syntax with an Error
type. This is because your error might be caught in higher level code with a catch
syntax.
It would be very confusing to catch a string message there and would make
debugging more painful.
For the same reason you should reject promises with Error
types.
Bad:
function calculateTotal(items: Item[]): number {
throw 'Not implemented.';
}
function get(): Promise<Item[]> {
return Promise.reject('Not implemented.');
}
Good:
function calculateTotal(items: Item[]): number {
throw new Error('Not implemented.');
}
function get(): Promise<Item[]> {
return Promise.reject(new Error('Not implemented.'));
}
// or equivalent to:
async function get(): Promise<Item[]> {
throw new Error('Not implemented.');
}
The benefit of using Error
types is that it is supported by the syntax try/catch/finally
and implicitly all errors have the stack
property which
is very powerful for debugging.
There are also other alternatives, not to use the throw
syntax and instead always return custom error objects. TypeScript makes this even easier.
Consider the following example:
type Result<R> = { isError: false, value: R };
type Failure<E> = { isError: true, error: E };
type Failable<R, E> = Result<R> | Failure<E>;
function calculateTotal(items: Item[]): Failable<number, 'empty'> {
if (items.length === 0) {
return { isError: true, error: 'empty' };
}
// ...
return { isError: false, value: 42 };
}
For the detailed explanation of this idea refer to the original post.
Don't ignore caught errors
Doing nothing with a caught error doesn't give you the ability to ever fix or react to said error. Logging the error to the console (console.log
) isn't much better as often it can get lost in a sea of things printed to the console. If you wrap any bit of code in a try/catch
it means you think an error may occur there and therefore you should have a plan, or create a code path, for when it occurs.
Bad:
try {
functionThatMightThrow();
} catch (error) {
console.log(error);
}
// or even worse
try {
functionThatMightThrow();
} catch (error) {
// ignore error
}
Good:
import { logger } from './logging'
try {
functionThatMightThrow();
} catch (error) {
logger.log(error);
}
Don't ignore rejected promises
For the same reason you shouldn't ignore caught errors from try/catch
.
Bad:
getUser()
.then((user: User) => {
return sendEmail(user.email, 'Welcome!');
})
.catch((error) => {
console.log(error);
});
Good:
import { logger } from './logging'
getUser()
.then((user: User) => {
return sendEmail(user.email, 'Welcome!');
})
.catch((error) => {
logger.log(error);
});
// or using the async/await syntax:
try {
const user = await getUser();
await sendEmail(user.email, 'Welcome!');
} catch (error) {
logger.log(error);
}