This article is loosely based off my recent video:
Hey everyone, Jason here ๐
Let's talk about something that many JavaScript developers love to hate: try-catch.
In this article, I want to explore:
- why try-catch can be frustrating
- discuss a common solution many have proposed, and
- introduce a pattern that I haven't seen enough people talk about
The Problem with try-catch
Consider this function:
function getUserAndPreference() {
try {
const user = getUser();
const preference = getUserPreference(user);
return [user, preference];
} catch (error) {
return null;
}
}
At first glance, this seems fine. But here's the issue: the try
block catches all errors within it. That means if getUser
or getUserPreference
throws an error, or even if there's a typo elsewhere, they all get caught in the same catch
block. This makes debugging and maintenance harder as your function grows.
To handle errors more granularly, you might consider wrapping each operation in its own try-catch
:
function getUserAndPreference() {
let user: User;
try {
user = getUser();
} catch (error) {
return null;
}
let preference: UserPreference;
try {
preference = getUserPreference(user);
} catch (error) {
return null;
}
return [user, preference];
}
But this pattern has its own issues:
-
Forced to use
let
instead ofconst
This pattern requires us to declare the variables and assign them in different scope. This forces us to replace our "supposedly"const
tolet
. -
Forced to use explicit typing
In the previous example, all variables are typed automatically thanks to TypeScript's type inference. However, this approach forces us to seperate the declaring and assigning of the variables, meaning that TypeScript couldn't determine the variables types at declaration and use implicit
any
for those variables!
To summarize, the 3 pain-points of try-catch are:
- Catch-all behaviour
- Forcing
let
instead ofconst
- Compromising type inference by TypeScript
The "Do Not Throw" Pattern
To address these issues, many developers advocate for a "do not throw" approach. Instead of throwing errors, functions return a tuple containing the error and the result.
Here's a really simple example of such concept:
const tryCatch = async <T>(
promise: Promise<T>,
): Promise<[error: null, result: T] | [error: Error]> => {
try {
return [null, await promise];
} catch (error) {
return [error];
}
};
Usage:
const [error, user] = await tryCatch(getUser());
if (error) {
// handle error
}
This pattern mitigates all 3 issues mentioned in the previous section: allows for more granular error handling, allows for using const
, and still keep TypeScript's type inference.
However, I find it not to be the most idiomatic way to JavaScript (handling errors as return values) and requires a utility function that's not standardized which causes never-ending discussions amongst engineers with different background and preferences.
Enter IIFE: Immediately Invoked Function Expression
IIFE, pronounced as Eevee (the Pokรฉmon), is a pattern that's been around since the earliest days of JavaScript.
const user = await (async () => {
try {
return await getUser();
} catch (error) {
return null;
}
})();
By wrapping the operation in an IIFE, we can use try-catch
as usual while eliminating all problems with try-catch
mentioned previously.
We can apply the same pattern to getUserPreference
:
const preference = await (async () => {
try {
return await getUserPreference(user);
} catch (error) {
return null;
}
})();
Now, our function looks like this:
async function getUserAndPreference() {
const user = await (async () => {
try {
return await getUser();
} catch (error) {
return null;
}
})();
if (!user) return null;
const preference = await (async () => {
try {
return await getUserPreference(user);
} catch (error) {
return null;
}
})();
if (!preference) return null;
return [user, preference];
}
This approach allows for granular error handling, maintains code integrity, and leverages type inferenceโall without any utility functions.
Looking Ahead: Do Expressions
There's a proposal for "do expressions" in JavaScript, currently at stage 1. It aims to bring similar functionality as IIFE but with cleaner syntax:
const getUserAndPreference = async () => {
const user = do {
try {
getUser();
} catch (error) {
// handle errors here
return null; // return from getUserAndPreference
}
};
// ...
};
Once this proposal is accepted as the language standard, try-catch IIFEs can migrate seamlessly to do expressions.
Final Thoughts
try-catch
isn't inherently evil. It's how we use it that matters. By leveraging IIFE, we can write cleaner, more maintainable code without relying on external utilities or compromising code integrity.
I hope you found this helpful! If you did, please like, give me a follow, and share with your friends. If you have any questions or ideas, feel free to comment and let me know.
Let me know if you'd like to explore more patterns or have any questions!
Top comments (6)
All of this is fine and does solve the problems you're talking about, but at the expense of readability. There's more visual clutter and cognitive load deciphering what multiple complex blocks mean (even with the potential
do
).I totally see where you're coming from!
IIFE can feel unfamiliar or visually โheavyโ at first โ especially if you're not used to wrapping async logic like that. But once it clicks, I actually find it declutters code by grouping logical units together.
Plus, IIFE has so many more use cases beyond just try-catch. My next video/article is probably going to dive deeper into it โ I honestly think IIFE is super underrated in the JS community, and I really want to advocate for it more.
That said, itโs all down to personal preference in the end. The best approach is whatever works for you! ๐ All I ask is to keep an open mind and try it out next time โ let me know what you think!
Curious โ would you lean toward the
[err, result]
tuple pattern or similar "do not throw" patterns instead?honestly i always end up wrestling with try-catch so seeing this iife trick feels like a shortcut - you think swapping little patterns like this really ends up changing how we build bigger stuff down the line or nah
Yeah, I think little patterns like this can make a difference.
IIFE groups logic cleanly โ especially when itโs a one-off that doesnโt need a name. It keeps things local and avoids the weird side effects of broad try-catch blocks.
If it ever feels messy, you can always extract it into a function and give it a name. I usually only do that when I actually need to reuse the error handling. (Which is also a nice way to reuse logic without abstracting too early, you know, naming is hard.)
Curious โ have you used IIFE in your projects before, or is this your first time seeing it used like this?
Nice post!
thanks man!