Error Handling for fetch in TypeScript

Introduction

The following post describes why and how you do error handling for fetch.

Why Care?

When you write code that does not handle errors, the code may break at runtime and when deployed to production. Getting PagerDuty calls at 3am are not fun, and are hard to debug because you’re sleepy. Doing error handling can both prevent those early morning PageDuty alerts, as well as ensure if they do occur, you have a better indication as to what went wrong, and if you need to act on it.

TypeScript can help you with types that make it more clear a piece of code can fail, and ensure you and other developers who build atop it months to years later also handle those errors. You just have to spend the time thinking about and writing the types.

Example of Code That Does Not Handle Errors

The following code is commonly used in Node.js and the Browser:

let value = await fetch('https://some.server.com/api/data').then( r => r.json() )Code language: JavaScript (javascript)

This code does not handle the following error conditions:
– if the URL is malformed
– if the fetch has a networking error
– if fetch gets a non-200 http status code response
– if the JSON sent back fails to parse
– if the JSON parses, but is in the incorrect type

You can possibly glean those errors from stack traces, but those aren’t always easy to read, can sometimes be red herring to the real problem sending you in the wrong direction, and sometimes can be ignored altogether. The above are a bit harder to ascertain at 3am with little sleep.

Option 1: Add a catch

The first step is to handle all errors unrelated to types. You do this either using a try/catch or a .catch. The above code mixes async/await style and Promise chain style. While you can do that, it is recommended to follow one or the other so the code is easier to read and debug.

If you’re choosing the async await style, it could be re-written like so:

try {
    let response = await fetch('<a href="https://some.server.com/api/data" target="_blank" rel="noreferrer noopener">https://some.server.com/api/data</a>')
    let json = response.json()
    ...
} catch(error) {
    console.log("error:", error)
}Code language: JavaScript (javascript)

If you’re using Promise chain style, it could look like so:

fetch('<a href="https://some.server.com/api/data" target="_blank" rel="noreferrer noopener">https://some.server.com/api/data</a>')
    .then( r => r.json() )
    .catch( error => console.log("error:", error))Code language: JavaScript (javascript)

Option 2: Add the never Return Type

If this code is in a function, you do not want the TypeScript types to lie to you. Given TypeScript is a gradually typed language, this means there are cases where it’s “mostly typed”, but not entirely 100% accurate. Take a look at the following function wrapping our fetch call:

let getData = ():SomeType => {
    let response = await fetch('<a href="https://some.server.com/api/data" target="_blank" rel="noreferrer noopener">https://some.server.com/api/data</a>')
    let json = response.json()
    return json as SomeType
}Code language: JavaScript (javascript)

The first of 2 issues here is your fetch call can fail with multiple issues, so if an error occurs, nothing is returned. The 2nd is the type casting as has no guarantee Instead, we should change our return type to accurately reflect that, changing from this:

let getData = ():SomeType => { ... }Code language: JavaScript (javascript)

To this:

let getData = ():SomeType | never => { ... }Code language: JavaScript (javascript)

The never indicates that the function will return your type _or_ never return. This forces all functions to handle that never; you or your fellow developers don’t have to remember this, TypeScript will tell you. In the case of using that function in an Angular router guard, a predicate function (a function that returns true or false), you can interpret that never as a false:

let canNavigate = async ():boolean => {
    try {
        let result = await getData()
        return result.userIsAllowed === true
    } catch(error) {
        return false
    }
}Code language: JavaScript (javascript)

Option 3: Add a Result Type

The above is a good first step, however, it now forces someone _else_ to handle the errors. Given TypeScript is gradual, if someone _else_ is not handling errors, your exception risks being uncaught. The best thing to do is never intentionally throw errors, nor allow ones in your code to propagate, since JavaScript is so bad at exception handling, and TypeScript never’s aren’t perfect. Instead, you return a single type that indicates the possibility of failure. There 3 common ones used in TypeScript:

Promise – native to all browsers, Node.js, and handles synchronous and asynchronous code; Errors are `unknown`
Observable – typically used in Angular, but supported everywhere you import the RxJS library, and handles synchronous and asynchronous code; Errors are typically typed Observable<never>
Result or Either – a TypeScript discriminated union; handles synchronous, Errors are typically just strings

The less uncommon are typed FP libraries like Effect or true-myth.

Let’s use Promise for now since the fetch and the above code uses Promises already. We’ll change our getData function from:

let getData = ():SomeType => {...}Code language: JavaScript (javascript)

To a type that more represents the possibility the function could succeed or fail:

let getData = ():Promise<SomeType> => {...}Code language: JavaScript (javascript)

While this doesn’t enforce someone adds a try/catch, there are some runtime enforcement’s and type helping TypeScript that will at least increase the chance the Promise‘s error condition is handled.

NOTE: I know it may be hard to divide “Promise is used for async” and “Promise is used to represent a function that can fail”. For now, just ignore the “Promises are required for async” part, and focus on Promise being a box that holds _either_ success or failure. The only way to _know_ typically if an either has success or failure is to open it, and those are done in type safe ways. JavaScript makes this confusing by newer versions of Node.js/Browser yelling at you for missing a catch in JavaScript, whereas TypeScript is more proactive via the compiler errors.

Uncaught promises will eventually explode. Using an Observable at least ensures it won’t “break out” of the Observable itself, resulting in an unhandled runtime exception.

However, using a Result can be the best option because it ensures a developer cannot get a the value they want unless they handle the error condition, or intentionally choose to ignore it. TypeScript enforces this. We’ll come back to the asynchronous version in another post, so just pay attention to the examples below in how they enforce the type check:

let getData = ():Result<SomeType> => {...}Code language: HTML, XML (xml)

This means to use that data, the developer must inspect the type. Inspecting a discriminant like this will ensure the user can only access value if it’s an Ok type, and the error property if it’s an Err type; the compiler is awesome like that:

let canNavigate = ():boolean => {
    let result = getData()
    if(result.type === 'Ok') {
        return result.value.userIsAllowed === true
    } else {
        return false
    }
}Code language: JavaScript (javascript)

Notice they can’t just write result.value because that property only exists if the Union type is of Ok; an Err does not have a value type, so the code won’t compile unless you first check the type using an if or switch statement.

Option 4: Check for an Ok Response

The fetch function has the built in ability check if the response is an http status code of 200 through 299 range, meaning it’s safe to use response.json() (or blob, or text, etc). If you didn’t get a 200-299 status code, you can be confident you did not get JSON back you were expecting.

fetch('<a href="https://some.server.com/api/data" target="_blank" rel="noreferrer noopener">https://some.server.com/api/data</a>')
.then( r => {
    if(r.ok) {
        return r.json()
    } else {
        // we have an http error code
        return Promise.reject(new Error(`HTTP Error code: ${r.statusCode}, reason: ${r.statusText}`))
    }
})Code language: JavaScript (javascript)

Since much of parsing code isn’t setup to handle a response already in an non-200 state, there is no point in running that code, so you can choose to exit early, or throw an Error/return a rejected Promise so your catch will handle it early. Importantly, though, you have an opportunity to inspect what went wrong with the request, clearly indicating this is NOT a JSON parsing or type narrowing error, but a problem with the API response itself. This is important in that the type of error you get back can dictate how the developer’s code will respond. The only way it can do that is if you create a different type so the code can tell the difference in the errors returned.

Caveat: Some API’s will send back text or JSON error text in the body that you _do_ have to parse, but in a separate code path.

Option 5: Validate Your URL Beforehand

If the URL you send to fetch is malformed, it’ll throw an exception before it even makes a network request. While you can rely on the .catch in the fetch promise chain to handle this, another option is to run it through JavaScript’s URL class. One horrible side-effect of that class constructor is if it notices the url is malformed, it’ll throw an exception.

const getURL = (url:string):string | never => { ... }Code language: JavaScript (javascript)

Notice since the new URL can possibly fail, we type it as “either a URL string, or it’ll never return because it exploded”. We can later use that to our advantage to distinguish between “the server had a problem” and “your JSON is messed up” and “your URL is malformed, bruh”. You can replace with Promise/Observable/Result too. Example:

const getURL = (url:string):Result<string> => {
    try {
        const urlValue = new URL(url)
        return Ok(urlvalue.href)
    } catch(error) {
        return Err(error.message)
    }
}Code language: PHP (php)

Option 6: Type Casting

Type casting, meaning converting from 1 type to the next, is all on the developer. Type narrowing can be a ton of work that is error prone, order important, and may/may not be thorough enough. This is particularly dangerous in JSON.parse because the return type says it’s an any. However, it’s _actually_ any | never, and in the case of response.json(), it’s Promise<any> meaning someone else needs to handle the error scenario. You _can_ use unknown to ensure you, and your fellow developers, are forced to type narrow:

const result = JSON.parse(someString)
if(typeof result !== 'undefined'
    && typeof result?.prop !== null
    && typeof result?.prop === 'string'
    && ... {
        return Ok(result as YourType)
    } else {
        return Err('Failed to cast JSON.parse object to YourType.')
    }
)Code language: JavaScript (javascript)

…but that’s a lot of no fun, dangerous work. Better to use a library that has already solved this problem like Zod or ArkType. It’ll ensure the types match up, and if not, give you an error response that _somewhat_ gives you a clue as to why the decoding went wrong, way more thorough and verbose than JSON.parse’s not so great runtime error messages.

const json = JSON.parse(someString)
const { success, data, error } = YourType.safeParse(someObject)
if(success) {
    return Ok(data)
} else {
    return Err(error)
}Code language: JavaScript (javascript)

Conclusions

As you can see, fetch has a lot of things that can go wrong, some can be ignored, and some can actually allow another code path such as retry to happen IF you know what went wrong in the fetching process. TypeScript can help enforce these paths are safe, and you can open up these paths safely now that know what possible things can go wrong in fetch. These are a malformed URL, a networking error, your JSON parsing fails, your JSON does not math your expected type(es), or the server returned an error response. Hopefully some of the above ensures you aren’t awoken in the middle of the night from your, or someone else’s code on your team.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *