JavaScript Errors Used to Be Simple
We had a dedicated channel in callbacks. We knew when something went wrong for that function.
fs.readFile('file.txt', (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});
You checked for the error. You dealt with it.
No surprises. No magic.
It wasn’t pretty, because callback hell. But it was clear.
Then Came Async/Await
It looked clean. Linear. Easy to follow. Arguably, it still is.
But we started throwing errors again. All in the same channel.
Like this:
fastify.get('/user/:id', async (req, reply) => {
const user = await getUser(req.params.id);
if (!user) throw fastify.httpErrors.notFound();
return user;
});
This seems fine—until you need to do more than one thing.
Suddenly, your catch
block becomes a patchwork of if-statements:
fastify.get('/user/:id', async (req, reply) => {
try {
const user = await getUser(req.params.id);
if (!user) throw fastify.httpErrors.notFound();
const data = await getUserData(user);
return data;
} catch (err) {
if (err.statusCode === 404) {
req.log.warn(`User not found: ${req.params.id}`);
return reply.code(404).send({ message: 'User not found' });
}
if (err.statusCode === 401) {
req.log.warn(`Unauthorized access`);
return reply.code(401).send({ message: 'Unauthorized' });
}
req.log.error(err);
return reply.code(500).send({ message: 'Unexpected error' });
}
});
You're using catch
not just for exceptions, but for expected things:
- A user not found
- Invalid auth
- Bad input
You're forced to reverse-engineer intent from the thrown error.
You lose clarity. You lose control.
Other Languages Seem To Do Better
Go
Go keeps it simple. Errors are values.
data, err := ioutil.ReadFile("file.txt")
if err != nil {
log.Fatal(err)
}
You deal with the error. Or you don’t. But you don’t ignore it.
Scala
Scala uses types to make the rules clear.
val result: Either[Throwable, String] = Try {
Files.readString(Path.of("file.txt"))
}.toEither
result match {
case Left(err) => println(s"Error: $err")
case Right(data) => println(s"Success: $data")
}
You must handle both outcomes.
No free passes. No silent failures.
Use Option
for missing values.
val maybeValue: Option[String] = Some("Hello")
val result = maybeValue.getOrElse("Default")
No null
. No undefined
. No guessing.
What JavaScript Could Be
We don’t have to do this:
try {
const data = await fs.promises.readFile('file.txt');
} catch (err) {
console.error(err);
}
We could do this:
const [err, data] = await to(fs.promises.readFile('file.txt'));
if (err) {
console.error('Failed to read file:', err);
return;
}
console.log('File contents:', data);
It’s clear. It’s honest. It works.
Or we use a result wrapper:
const result = await Result.try(() => fs.promises.readFile('file.txt'));
if (result.isErr) {
console.error(result.error);
} else {
console.log(result.value);
}
You know what's expected. You know what blew up.
Want to Write Better Code?
Here are some tools to help with that:
-
await-to-js
—[err, data]
pattern -
neverthrow
— type-safe error handling -
oxide.ts
— Rust-styleResult
andOption
types for TypeScript
One Last Thing
This is a bit of the old “you made your bed, now lie in it.”
We started throwing everything into a single channel.
We didn’t think it through.
But it’s fixable.
Choose better patterns.
Throw less.
Write what you mean.
Top comments (2)
Your post came right in time as I was preparing to rewrite some of the old code of a JavaScript project. It will really help me to have more control over the errors my project throws at me 🙌🏼
Good luck, I'm sure it may be a challenge to unwind it all*