Reasoning About 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, to a fault.
Now errors are 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*