DEV Community

Jesse Warden
Jesse Warden

Posted on • Originally published at jessewarden.com

4

Error Handling for fetch in TypeScript

Error Handling for fetch in TypeScript

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() )
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 => { ... }
Enter fullscreen mode Exit fullscreen mode

To this:

let getData = ():SomeType | never => { ... }
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 => {...}
Enter fullscreen mode Exit fullscreen mode

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

let getData = ():Promise<SomeType> => {...}
Enter fullscreen mode Exit fullscreen mode

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> => {...}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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}`))
    }
})
Enter fullscreen mode Exit fullscreen mode

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 => { ... }
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.')
    }
)
Enter fullscreen mode Exit fullscreen mode

…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)
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (15)

Collapse
 
webjose profile image
José Pablo Ramírez Vargas • Edited

Also, you could use wj-config's ability to create URL-building functions from configuration to ensure your URL's are never malformed.

Assuming a configuration like this:

{
  ...,
  urls: {
    rootPath: '/api',
    users: {
      rootPath: '/users',
      byId: '/{userId}',
      all: '',
    },
    // ETC.  Other sections.
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

You could create a configuration object like this:

import wjConfig from 'wj-config';
import myConfig from './config.json';

export default await wjConfig()
  .addObject(myConfig)
  .createUrlFunctions('urls')
  .build();

Enter fullscreen mode Exit fullscreen mode

Then import it wherever needed:

import config from './config.js';

const userUrl = config.urls.users.byId({ userId: 123 });
// Now use userUrl with full confidence that that URL is not malformed.
// Doing this saves you from having to check in runtime the URL's validity.
const response = await fetcher
  .for<200, MyData>()
  .for<400, Error[]>()
  .fetch(userUrl, options);
...
Enter fullscreen mode Exit fullscreen mode

Full documentation: URL Building Functions

Collapse
 
jesterxl profile image
Jesse Warden

Weird, that library is a JavaScript library, not a TypeScript library. The point wasn't so much "The URL can be bad", but rather, new URL is not safe to utilize, ensure you wrap it with a Result type.

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

What do you mean "not a TypeScript" library? Also, which one? wj-config or dr-fetch?

Thread Thread
 
webjose profile image
José Pablo Ramírez Vargas

Also, you don't need to worry about URL's so long you control it. Again, actual validations are only needed on foreign data. Data you control, like your application's own URL's are expected to be always perfect. If not, the developer will fix before deploying.

Thread Thread
 
jesterxl profile image
Jesse Warden

TypeScript does not yell at you for going new URL('cow'), compiles, and your code then explodes at runtime. We most certainly need to worry.

Thread Thread
 
jesterxl profile image
Jesse Warden

This article is about utilizing types to ensure your code is safe for handling errors from fetch. It doesn't appear wj-config has compile time safety for creating URL's. It looks like you can still create bad ones, and you won't find out until you run the code, as runtime exceptions vs. compile time safety with TypeScript.

Thread Thread
 
webjose profile image
José Pablo Ramírez Vargas

The developer would catch this by testing.

Unless the URL comes from user input or a similarly untrusted source, you don't need to validate the URL's you, as developer, hardcode or sets in config files.

Thread Thread
 
webjose profile image
José Pablo Ramírez Vargas

And this is a problem because...? Again, configuration files are created by developers and are a trusted source of data. Trusted sources don't require validation.

Let me know if I should explain this differently.

Thread Thread
 
jesterxl profile image
Jesse Warden

An example would be YAML. Many think infrastructure as code should be actual code; like TypeScript or PKL. This is because we can write tests for that code, and use language features like types. YAML has none of that and leads to a lot of failed deployments.

Configuration, created by developers, isn't trustable; we need types and/or tests to validate it before we actually run it by shifting left; finding out sooner, failing faster.

Thread Thread
 
webjose profile image
José Pablo Ramírez Vargas

Hello. It seems that you may have deleted one message. I see 2 of my messages in sequence. I don't remember what that message said, so replying to this one is more difficult.

We are talking here about developer-created configuration. You say it is not trusted. You seem to be confused about the definition of "trusted". If you cannot trust yourself, who will you trust? Are you thinkink you might sabotage yourself? This is so weird.

Write types, yes, write tests, yes, validate! All that, Yes. But do it during the development cycle. Once development is done, don't add runtime checks because the configuration has been thoroughly tested.

Pay the price during development, don't repeatedly pay it during runtime.

Collapse
 
webjose profile image
José Pablo Ramírez Vargas • Edited

You could also type the response depending on the value of the status code using dr-fetch:

import { DrFetch } from 'dr-fetch';

const fetcher = new DrFetch();

const response = await fetcher
  .for<200, MyData>()
  .for<400, Error[]>()
  .fetch(url, options);

// Now response is fully typed.  Just write IF statements based on status code or ok:
if (response.ok) { // Also works:  response.status === 200
  response.body; // This is of type MyData.
else {
  response.body; // This is of type Error[].
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jesterxl profile image
Jesse Warden • Edited

Do you know how thorough dr-fetch is for data validation? Angular does the same thing with this.http.post<MyData> but does NOT actually validate the JSON coming back matches MyData, hence why we have to use Zod under the covers, so has me curious.

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

If you or your team are the author of the API, you don't need to waste cycles actually testing the veracity of the TS types. You only need actual validation on data you don't control, which is the lesser cases.

Even when consuming 3rd party API's like a currency API, it is almost a guarantee that actual type checking is wasted effort.

Basically, as a rule of thumb, actual validation should be in place in just few cases, such as reading a value from session/local storage, validating user input, uploaded data files. That sort of thing. API's? Rarely if ever.

Thread Thread
 
jesterxl profile image
Jesse Warden

We certainly do need to test our API's. There is no guarantee just because we wrote it, the code is bug free. We should utilize both types and automated unit and accepteance tests to validate it. Using types helps in a variety of ways to both ensure the API and UI are on the same page with the types, the domain objects, the contract we're expecting to use even if we are also the consumers, and automated tests should be run as contract tests to ensure these assumptions actually code. We could be in a situation where our API is deployed independently of the UI, and this could break if the UI is deployed without updating the types. Types can significantly help, quickly identify these areas, ensure we've handled situations where they break, and also reduce how much testing we have to do, instead relying on the compiler to find these issues.

Thread Thread
 
webjose profile image
José Pablo Ramírez Vargas

Yes, unit-test, debug, refine, all that. Just don't add overhead to consumers of the API. It is expected that the API has gone (past tense) through this process. Once you have ensured its quality, why would you waste CPU cycles checking every single entity you pull from the (already-tested-and-debugged) API?? It makes zero sense.

Heroku

Build apps, not infrastructure.

Dealing with servers, hardware, and infrastructure can take up your valuable time. Discover the benefits of Heroku, the PaaS of choice for developers since 2007.

Visit Site

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay