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!
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
).