• 7 minutes
  • September 30, 2023

TypeScript with Go/Rust errors? No try/catch? Heresy.

So, let’s start with a little backstory about me. I am a software developer with around 10 years of experience, initially working with PHP and then gradually transitioning to JavaScript. Also, this is my first article ever, so please be understanding 🙂

I started using TypeScript somewhere around 5 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 common thing that Go and Rust share? ERRORS. The one 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 like a trivial thing; however, it’s a game-changer.

Let’s walk through these languages. We will not dive deep into each; we just want to know the general approach.

Let’s start with JavaScript / TypeScript and a little game.

Give yourself 5 seconds to look at 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 of you 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:

try {
    let data = "Hello";
} catch (err) {
    console.error(err);
}

JavaScript doesn’t know, JavaScript doesn’t care; YOU should know.

Second thing, this is a 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 throw. Of course, we can somehow guess by the error message, but for bigger services / functions, with a lot of places where error can happen? Are You sure You are handling all of them properly by one try / catch?

Ok, it’s time to stop picking on JS and move to something else. Let’s start with Go:

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, which is returning either a file or an error. And you will be seeing this a lot, mostly because we know which functions return errors, always. 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 totaly 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 details, 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 in more convenient ways, 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 know wherever there might be an error, always. 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 just doesn’t have 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 really 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 error. Sounds familiar?

Also, second lie, we need some try / catch. The good thing is we only need at least two, not 100000.

export function safe<T>(promise: Promise<T>): Promise<Safe<T>>;
export function safe<T>(func: () => T): Safe<T>;
export function safe<T>(
    promiseOrFunc: Promise<T> | (() => T),
): Promise<Safe<T>> | Safe<T> {
    if (promiseOrFunc instanceof Promise) {
        return safeAsync(promiseOrFunc);
    }
    return safeSync(promiseOrFunc);
}

async function safeAsync<T>(promise: Promise<T>): Promise<Safe<T>> {
    try {
        const data = await promise;
        return { data, success: true };
    } catch (e) {
        console.error(e);
        if (e instanceof Error) {
            return { success: false, error: e.message };
        }
        return { success: false, error: "Something went wrong" };
    }
}

function safeSync<T>(func: () => T): Safe<T> {
    try {
        const data = func();
        return { data, success: true };
    } catch (e) {
        console.error(e);
        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 together 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));
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:

  • no try-catch
  • we handle each error where it occurs
  • 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, then the body.data will not exist. LSP will remind us of it by throwing a “Property ‘data’ does not exist on type ‘Safe'”. And it’s all Thanks to the simple Safe type we created. And it also works for error message; we don’t have access to body.error until we check for !body.success.

Here is a moment we should really appreciate the TypeScript and how it changed the JavaScript world.

The same goes for:

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 need to remember to wrap Promises / functions that can throw errors with our safe wrapper. This “we need to know” is a language limitation which 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 / catch anywhere, with every error handled where it appeared, with a nice flow of logic… it just looks nice.

Here is a real life usage (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;

Few things to point out:

  • our custom function grpcSafe to help us with grpc callback
  • createMetadata return Safe inside, so we don’t need to wrap it.
  • zod library is using 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 great fit for you too 🙂

Also, I hope this article was interesting for you. I hope to create more of them to share my thoughts and ideas.

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

Subscribe for updates