So, let’s start with a little backstory about me. I am a software developer with around ten years of experience, initially working with PHP and then gradually transitioning to JavaScript.
I started using TypeScript somewhere around five years ago, and since then, I have never gone back to JavaScript. The moment I started using it, I thought it was the best programming language ever created. Everyone loves it; everyone uses it… it’s just the best one, right? Right? RIGHT?
Yeah, and then I started playing around with other languages, more modern ones. First was Go, and then I slowly added Rust to my list (thanks, Prime).
It’s hard to miss things when you don’t know different things exist.
What am I talking about? What is the common thing that Go and Rust share? Errors. The thing that stood out the most for me. And, more specifically, how these languages handle them.
JavaScript relies on throwing exceptions to handle errors, whereas Go and Rust treat them as values. You might think this is not such a big deal… but, boy, it may sound trivial; however, it’s a game-changer.
Let’s walk through them. We will not dive deep into each language; we want to know the general approach.
Let’s start with JavaScript/TypeScript and a little game.
Give yourself five seconds to review the code below and answer why we need to wrap it in try/catch.
try {
const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
return;
}
// handle response
} catch (e) {
// handle error
return;
}
So, I assume most of you guessed that even though we are checking for response.ok, the fetch method can still throw an error. The response.ok “catches” only 4xx and 5xx network errors. But when the network itself fails, it throws an error.
But I wonder how many guessed that the JSON.stringify will also throw an error. The reason why is that the request object contains the bigint (2n) variable, which JSON doesn’t know how to stringify.
So the first problem is, and personally, I believe it’s the biggest JavaScript problem ever: we don’t know what can throw an error. From a JavaScript error perspective, it’s the same as the following:
try {
let data = “Hello”;
} catch (err) {
console.error(err);
}
JavaScript doesn’t know; JavaScript doesn’t care. You should know.
Second thing, this is perfectly viable code:
const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
return;
}
No errors, no linters, even though this can break your app.
Right now, in my head, I can hear, “What’s the problem, just use try/catch everywhere.” Here comes the third problem: we don’t know which one is thrown. Of course, we can somehow guess by the error message, but what about bigger services/functions with many places where errors can happen? Are you sure you are handling all of them properly with one try/catch?
OK, it’s time to stop picking on JS and move to something else. Let’s start with this Go code:
f, err := os.Open(“filename.ext”)
if err != nil {
log.Fatal(err)
}
// do something with the open *File f
We are trying to open a file that returns a file or an error. And you will see this a lot, mostly because we know which functions always return errors. You never miss one. Here is the first example of treating the error as a value. You specify which function can return them, you return them, you assign them, you check them, you work with them.
It’s also not so colorful, and it’s also one of the things Go gets criticized for— the “error-checking code,” where if err != nil { …. sometimes takes more lines of code than the rest.
if err != nil {
…
if err != nil {
…
if err != nil {
…
}
}
}
if err != nil {
…
}
…
if err != nil {
…
}
Still totally worth the effort, trust me.
And finally, Rust:
let greeting_file_result = File::open(“hello.txt”);
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};
The most verbose of the three shown here and, ironically, the best one. So, first of all, Rust handles the errors using its amazing enums (they are not the same as TypeScript enums!). Without going into detail, what is important here is that it uses an enum called Result with two variants: Ok and Err. As you might guess, Ok holds a value and Err holds…surprise, an error :D.
It also has a lot of ways to deal with them more conveniently to mitigate the Go problem. The most well-known one is the ? operator.
let greeting_file_result = File::open(“hello.txt”)?;
The summary here is that both Go and Rust always know where there might be an error. And they force you to deal with it right where it appears (mostly). No hidden ones, no guessing, no breaking app with a surprise face.
And this approach is just better. By A MILE.
OK, it’s time to be honest; I lied a little bit. We cannot make TypeScript errors work like the Go / Rust ones. The limiting factor here is the language itself; it doesn’t have the proper tools to do that.
But what we can do is try to make it similar. And make it simple.
Starting with this:
export type Safe<T> =
| {
success: true;
data: T;
}
| {
success: false;
error: string;
};
Nothing fancy here, just a simple generic type. But this little baby can totally change the code. As you might notice, the biggest difference here is we are either returning data or errors. Sounds familiar?
Also… the second lie, we do need a few try/catches. The good thing is we only need about two, not 100,000.
export function safe<T>(promise: Promise<T>, err?: string): Promise<Safe<T>>;
export function safe<T>(func: () => T, err?: string): Safe<T>;
export function safe<T>(
promiseOrFunc: Promise<T> | (() => T),
err?: string,
): Promise<Safe<T>> | Safe<T> {
if (promiseOrFunc instanceof Promise) {
return safeAsync(promiseOrFunc, err);
}
return safeSync(promiseOrFunc, err);
}
async function safeAsync<T>(
promise: Promise<T>,
err?: string
): Promise<Safe<T>> {
try {
const data = await promise;
return { data, success: true };
} catch (e) {
console.error(e);
if (err !== undefined) {
return { success: false, error: err };
}
if (e instanceof Error) {
return { success: false, error: e.message };
}
return { success: false, error: "Something went wrong" };
}
}
function safeSync<T>(
func: () => T,
err?: string
): Safe<T> {
try {
const data = func();
return { data, success: true };
} catch (e) {
console.error(e);
if (err !== undefined) {
return { success: false, error: err };
}
if (e instanceof Error) {
return { success: false, error: e.message };
}
return { success: false, error: "Something went wrong" };
}
}
“Wow, what a genius. He created a wrapper for try/catch.” Yes, you are right; this is just a wrapper with our Safe type as the return one. But sometimes simple things are all you need. Let’s combine them with the example from above.
Old one (16 lines):
try {
const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
// handle network error
return;
}
// handle response
} catch (e) {
// handle error
return;
}
New one (20 lines):
const request = { name: “test”, value: 2n };
const body = safe(
() => JSON.stringify(request),
“Failed to serialize request”,
);
if (!body.success) {
// handle error (body.error)
return;
}
const response = await safe(
fetch("https://example.com", {
method: “POST”,
body: body.data,
}),
);
if (!response.success) {
// handle error (response.error)
return;
}
if (!response.data.ok) {
// handle network error
return;
}
// handle response (body.data)
So yes, our new solution is longer, but it performs better because of the following reasons:
– no try/catch
– we handle each error where it occurs
– we can specify an error message for a specific function
– we have a nice top-to-bottom logic, all errors on top, then only the response at the bottom
But now comes the ace. What will happen if we forget to check this one:
if (!body.success) {
// handle error (body.error)
return;
}
The thing is… we can’t. Yes, we must do that check. If we don’t, the body.data will not exist. LSP will remind us by throwing a “Property ‘data’ does not exist on type ‘Safe<string>’” error. And it’s all thanks to the simple Safe type we created. And it also works for error messages. We don’t have access to body.error until we check for !body.success.
Here is a moment we should appreciate TypeScript and how it changed the JavaScript world.
The same goes for the following:
if (!response.success) {
// handle error (response.error)
return;
}
We cannot remove the !response.success check because, otherwise, the response.data will not exist.
Of course, our solution doesn’t come without its problems. The biggest one is that you must remember to wrap Promises/functions that can throw errors with our safe wrapper. This “we need to know” is a language limitation we cannot overcome.
It may sound hard, but it isn’t. You soon start to realize that almost all Promises you have in your code can throw errors and the synchronous functions that can, you know about them, and there aren’t so many of them.
Still, you might be asking, is it worth it? We think it is, and it’s working perfectly in our team :). When you look at a bigger service file, with no try/catches anywhere, with every error handled where it appeared, with a nice flow of logic… it just looks nice.
Here is a real-life example using the SvelteKit FormAction:
export const actions = {
createEmail: async ({ locals, request }) => {
const end = perf(“CreateEmail”);
const form = await safe(request.formData());
if (!form.success) {
return fail(400, { error: form.error });
}
const schema = z
.object({
emailTo: z.string().email(),
emailName: z.string().min(1),
emailSubject: z.string().min(1),
emailHtml: z.string().min(1),
})
.safeParse({
emailTo: form.data.get("emailTo"),
emailName: form.data.get("emailName"),
emailSubject: form.data.get("emailSubject"),
emailHtml: form.data.get("emailHtml"),
});
if (!schema.success) {
console.error(schema.error.flatten());
return fail(400, { form: schema.error.flatten().fieldErrors });
}
const metadata = createMetadata(URI_GRPC, locals.user.key)
if (!metadata.success) {
return fail(400, { error: metadata.error });
}
const response = await new Promise<Safe<Email__Output>>((res) => {
usersClient.createEmail(schema.data, metadata.data, grpcSafe(res));
});
if (!response.success) {
return fail(400, { error: response.error });
}
end();
return {
email: response.data,
};
},
} satisfies Actions;
Here’s a few things to point out:
- our custom function grpcSafe helps us with the gGRPC callback.
- createMetadata returns Safe inside, so we don’t need to wrap it.
- the zod library uses the same pattern 🙂 If we don’t do schema.success check, we don’t have access to schema.data.
Doesn’t it look clean? So try it out! Maybe it will be a great fit for you too 🙂
Thanks for reading.
P.S. Looks similar?
f, err := os.Open(“filename.ext”)
if err != nil {
log.Fatal(err)
}
// do something with the open *File f
const response = await safe(fetch(“https://example.com"));
if (!response.success) {
console.error(response.error);
return;
}
// do something with the response.data
TypeScript With Go and Rust Errors? No Try/Catch? Heresy was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.