> For the complete documentation index, see [llms.txt](https://engineering.umh.app/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://engineering.umh.app/engineering/development-process/how-to-build/coding-standards/languages/typescript/gracefully-handling-runtime-errors-with-javascript.md).

# Gracefully handling runtime errors with JavaScript

{% hint style="info" %}
This page's examples are written in TypeScript, but you can apply exactly the same principles in plain JavaScript codebases too.
{% endhint %}

If you've worked on a big frontend codebase, you'll surely have seen huge `try/catch` clauses that exist for the sake of preventing a runtime fail. Since JavaScript doesn't compile, there's nothing between a developer and a user when things go wrong.

JavaScript fails during runtime. In other words: while someone is using your program. `try/catch` is often the (wrong) shortcut to avoid runtime errors.

Before we dive into our proposed usage of try/catch, let's start by understanding what a try/catch block is.

### What is try/catch?

Simply put, it's an A/B route. In the `try` path, we attempt to execute some code. That's plan A. And in the `catch` path, we have plan B. Plan B is sort of an escape hatch where you can run code that recovers your program when an error occurs.

```typescript
async function tryCatchExample() {
    try {
        // plan A: fetching something from api/example and
        // returning the parsed json of the response
        const response = await fetch(`/api/example`)
        return response.json();
    } catch (error) {
        // plan B: the fetch failed - let's log it here
        // this way we ensure our program doesn't crash while api/example has a problem
        console.error(`Failed to fetch from /api/example!\n\n${error}`)
    }
}
```

If you've worked with a reasonable programming language before, you've probably spotted several red flags in the example above. But in the JS-world, it's perfectly normal code, even used by [w3schools in an example for async/await.](https://www.w3schools.com/js/tryit.asp?filename=tryjs_async_vs_promise_2)

In case you're not sure what I mean, here's the red flags:

* `catch` doesn't know if the `fetch` or if `response.json()` failed
* `error` is inherently of type `unknown` and must be checked with `instanceof`
* we can't log the error, because we don't know the error type:
  * if `error` is a plain object, our logger writes `[object Object]`; if it's an array, it writes the elements joined by commas
* if another function calls `tryCatchExample`, it doesn't know that it can fail

### How things get messy in the real world

Now that we understand what a try/catch block is, and what sort of pitfalls it allows us to run into, let's take a look at a real-world example.

First of all, it's rare to find big try/catch blocks in brand new code. They usually develop over the span of weeks, months and years.

So in this example, we'll look at how `loadProfile` evolved into a try/catch that - in our own terms - becomes an anti-pattern.

Here's what `loadProfile` looked like at the start:

```ts
async function loadProfile(userId: string) {
  let profile: Profile | null = null;
  try {
    const response = await fetch(`/api/users/${userId}`);
    profile = await response.json();
  } catch (error) {
    showToast('Could not load the profile.');
  }
  return profile;
}
```

But as it always happens with code, we needed a slightly different arrangement of the profile, so we have a helper that creates it. We added the code into the `try` block, since it contextually makes sense to do that right after getting the body.

A few weeks later, we introduced a telemetry tool that helps us track features, performance, etc. It's in the same try/catch.

Now we ended up in a situation where try/catch wraps 4 different contextual actions, and returns the same toast in all cases:

```ts
async function loadProfile(userId: string) {
  let profile: Profile | null = null;
  try {
    const response = await fetch(`/api/users/${userId}`);
    const body = await response.json();
    profile = mapProfile(body);
    analytics.track('profile_loaded');
  } catch (error) {
    showToast('Could not load the profile.');
  }
  return profile;
}
```

We now reached our worst case scenario for try/catch blocks. There are five different things that can go wrong in this version:

* the network request fails
* the server returns something that is not JSON
* `mapProfile` has a bug and throws
* the analytics service is down
* the server returns a 404 error

The first four all end up in the same `catch`. The user sees the same message in every case. The error report looks the same too. So we cannot tell a network problem from a bug in our own code.

The 404 is even trickier. It never reaches the `catch` at all. `fetch` treats a 404 response as a success. So the error page goes into `mapProfile` as if it were a profile. [Learn more about how fetch handles error responses](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok).

On top of that, the code is harder to read. What happens when the request fails? You have to scroll down to the `catch` to find out. And `profile` only gets its value inside the block. So it has to be declared with `let` further up.

### The consequences of wrapping large bits of code in single try/catch blocks

Let's implement `loadProfile` in our profile page now. From the outside, `loadProfile` looks harmless. Its signature says it returns a `Profile` or `null`. A caller would use it like this:

```ts
async function openProfilePage(userId: string) {
  const profile = await loadProfile(userId);
  if (profile === null) {
    return;
  }
  renderProfile(profile);
}
```

When `profile` is `null`, the caller knows that there's no profile. That is all it knows. It cannot retry after a network error. It cannot redirect when the user does not exist. It cannot report a bug in `mapProfile`. All of these arrive as the same `null`. A toast has already been shown, so the caller cannot choose a different message either.

The caller also cannot be sure that `null` is the only way `loadProfile` fails. TypeScript has no way to declare which errors a function throws. So the signature stays the same when a later change moves a throwing call outside the `try`. The caller cannot see that change. [Here's an interesting issue in the TypeScript repo about this](https://github.com/microsoft/TypeScript/issues/13219).

The only safe option for the caller is to add a `try` and `catch` of its own:

```ts
async function openProfilePage(userId: string) {
  try {
    const profile = await loadProfile(userId);
    if (profile === null) {
      return;
    }
    renderProfile(profile);
  } catch (error) {
    showToast('Something went wrong.');
  }
}
```

Welcome to try/catch hell. The function that calls `openProfilePage` is now in the same position: It has no clue what `error` is, nor what could go wrong inside of its own `try/catch`. So is the function above that. Every layer catches everything and learns nothing about the root cause.

### That's why we return errors instead of throwing

The alternative is to make failure part of the return value. A function that can fail returns either a success value or an error. Its signature says so. The caller has to look at the result before it can use the value. So it cannot forget the failure case.

Go and Rust handle errors this way. In Go, a function returns its error as a second value. In Rust, the `Result` type is either a success value or an error. [Learn more about errors as values in Go](https://go.dev/blog/errors-are-values) and [learn more about Rust's Result type](https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html).

For promises, our codebase has a small helper called `tryAwait`. It waits for the promise and returns the outcome as a value. When the promise succeeds, you get `ok: true` and the result. When it fails, you get `ok: false` and the error.

That way, you check `ok` right after each call. When something went wrong, you return early. Here's the same function, rewritten with `tryAwait`:

```ts
import { tryAwait } from '$lib/utils/tryAwait';

type ProfileFailure = {
  reason: 'request-failed' | 'unexpected-status' | 'invalid-json';
  cause: unknown;
};

type ProfileResult =
  | { ok: true; value: Profile }
  | { ok: false; error: ProfileFailure };

async function loadProfile(userId: string): Promise<ProfileResult> {
  const request = await tryAwait(fetch(`/api/users/${userId}`));
  if (!request.ok) {
    return { ok: false, error: { reason: 'request-failed', cause: request.error } };
  }

  const response = request.value;
  if (!response.ok) {
    return { ok: false, error: { reason: 'unexpected-status', cause: response.status } };
  }

  const bodyRead = await tryAwait(response.json());
  if (!bodyRead.ok) {
    return { ok: false, error: { reason: 'invalid-json', cause: bodyRead.error } };
  }

  return { ok: true, value: mapProfile(bodyRead.value) };
}
```

Now each step that can fail has its own check right below it. Each failure also has its own reason. So the message for the user and the error report can differ from case to case.

A bug in our own code is no longer caught here either. Instead, it shows up in error monitoring as what it is: a bug, not a failed profile load.

We also moved the analytics call out to the code that calls `loadProfile`. An analytics outage can no longer break loading the profile.

You might have noticed that the two `ok` checks use different variable names. That's on purpose. `request.ok` tells us whether the request completed. `response.ok` tells us whether the server answered with a success status.

Let's take a look at our caller and how it now understands if and what went wrong:

```ts
async function openProfilePage(userId: string) {
  const profile = await loadProfile(userId);
  if (!profile.ok) {
    showProfileLoadError(profile.error.reason);
    return;
  }
  renderProfile(profile.value);
}
```

If you're curious where the idea comes from: `tryAwait` is heavily inspired by [Arthur Fiorette's proposal to add a try operator to JavaScript](https://github.com/arthurfiorette/proposal-try-operator).

### Passing a failure up to the caller

With `try` and `catch`, every layer that catches an error tends to handle it on the spot. `loadProfile` showed a toast. `openProfilePage` showed a second one. If both of them also sent an error report, the same network failure reached telemetry twice, under two different messages.

With results, a function that can't do anything useful about a failure returns it to its caller unchanged. Only one site handles the failure: the one that can act on it. That is usually the page or the event handler that started the work. It reports the failure once and shows the user one message. The layers in between contain no toast and no telemetry call.

```ts
// loadProfile doesn't handle the error anymore. Its only job is to return it.
async function loadProfile(userId: string): Promise<ProfileResult> {
  // as above
}

// the layer in between is just there to check if the page can load
async function loadProfilePage(userId: string): Promise<ProfilePageResult> {
  const profile = await loadProfile(userId);
  if (!profile.ok) {
    return profile;
  }

  const activity = await loadRecentActivity(userId);
  if (!activity.ok) {
    return activity;
  }

  return { ok: true, value: { profile: profile.value, activity: activity.value } };
}

// the top of the site checks if something went wrong while loading
// this concept is known as 'bubbling up' an error
async function onProfileLinkClick(userId: string) {
  const page = await loadProfilePage(userId);
  if (!page.ok) {
    // error is bubbled up so it's handled once - no matter where it came from
    telemetry.reportError(t.profile.loadFailed, page.error.cause);
    showToast(messageForProfileFailure(page.error.reason));
    return;
  }
  renderProfilePage(page.value);
}
```

For this to work, the error type of `ProfilePageResult` has to include the error types of every result it passes on. That's a small price. The compiler then tells us when a new failure reason appears somewhere down the chain. The top site decides what to show for it.

### Other ways to avoid try and catch

`tryAwait` covers promises. One more case needs a slightly different approach.

#### Cleaning up after a call

When a call returns its failure as a value, the next line runs either way. So the cleanup can simply go on that line. You don't need a `finally` block.

```ts
const upload = await tryAwait(uploadFile(file));
clearTimeout(progressTimeout);
if (!upload.ok) {
  return { ok: false, error: { reason: 'upload-failed', cause: upload.error } };
}
```

### When to use try and catch

That said, `try` and `catch` are still the right choice whenever we can't predict how a call fails. In those cases, we keep only that one call inside the `try` block. Typical examples are:

* a call into a third-party library, a browser API, or a web worker
* a small helper whose only job is to turn a thrown error into a returned value, like `tryAwait` or the wrapper example below
* the code that reports errors to our monitoring, because a failed report cannot be reported anywhere else

Sometimes a feature must never break the page. Even then, it's better to wrap only the one call that can fail for reasons outside our code. The rest of the feature stays outside the `try`.

#### We use try/catch to wrap ECMAScript methods that throw by design

Some functions we depend on throw when they fail, and give us no other way to learn about it. Built-in functions and third-party libraries are the usual sources. For these, a small wrapper does the job well. It contains the single call in a `try` and `catch` and returns the outcome as a value. The wrapper is the only place that needs `try` and `catch`. Every caller checks the result the same way it checks `tryAwait`.

The wrapper's signature now tells the caller that the call can fail. The thrown error never did that. `JSON.parse` [(mdn docs)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse) is a typical case. It throws when the text isn't valid JSON.

```ts
type SettingsParseResult =
  | { ok: true; value: Settings }
  | { ok: false; error: unknown };

function parseStoredSettings(settingsText: string): SettingsParseResult {
  try {
    return { ok: true, value: JSON.parse(settingsText) };
  } catch (error) {
    return { ok: false, error };
  }
}
```

This simple wrapper lets you safely use the `JSON.parse` method without worrying about a runtime error that will crash your program.

### Error handling in Svelte components

Our Svelte components don't contain `try`, `catch`, or `finally`. Instead, code that can fail goes into a function in the `utils` folder next to the component. That function returns the outcome as a value. The component simply shows one view for success and another for failure:

```svelte
<script lang="ts">
  import { loadProfile } from './utils/load-profile';

  let { userId }: { userId: string } = $props();
</script>

{#await loadProfile(userId) then profileResult}
  {#if profileResult.ok}
    <ProfileCard profile={profileResult.value} />
  {:else}
    <ProfileLoadError />
  {/if}
{/await}
```

You might wonder whether Svelte's error boundary could take care of this. Unfortunately, it only catches errors that happen while a component renders. It doesn't catch errors in click handlers, in timers, or in other async code. [Learn more about error boundaries in Svelte](https://svelte.dev/docs/svelte/svelte-boundary).

### Questions for code review

* Does a `try` block contain more than one step that can fail?
* Can the `catch` tell which step failed?
* Would a bug in our own code be reported as an expected failure?
* Is a variable declared with `let` only because it gets its value inside a `try` block?
* Does my Svelte component **need** `try`, `catch`, or `finally`?
* Does the code check `response.ok` after each `fetch` call?
* Is a `try` block wrapped around a call that already returns its failure as a value?


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://engineering.umh.app/engineering/development-process/how-to-build/coding-standards/languages/typescript/gracefully-handling-runtime-errors-with-javascript.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
