Error Handling for fetch in TypeScript
Jesse Warden

Jesse Warden @jesterxl

About: I write code, front-end and back-end, and like deploying it on AWS. Software Developer for 20 years, and still love it. Amateur Powerlifter & Parkourist.

Location:
Richmond, Virginia, USA
Joined:
Nov 19, 2018

Error Handling for fetch in TypeScript

Publish Date: Feb 18
7 15

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.

Comments 15 total

  • José Pablo Ramírez Vargas
    José Pablo Ramírez VargasFeb 24, 2025

    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
    • Jesse Warden
      Jesse WardenFeb 24, 2025

      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.

      • José Pablo Ramírez Vargas
        José Pablo Ramírez VargasFeb 24, 2025

        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.

        • Jesse Warden
          Jesse WardenFeb 24, 2025

          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.

          • José Pablo Ramírez Vargas
            José Pablo Ramírez VargasFeb 24, 2025

            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.

  • José Pablo Ramírez Vargas
    José Pablo Ramírez VargasFeb 24, 2025

    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

    • Jesse Warden
      Jesse WardenFeb 24, 2025

      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.

      • José Pablo Ramírez Vargas
        José Pablo Ramírez VargasFeb 24, 2025

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

        • José Pablo Ramírez Vargas
          José Pablo Ramírez VargasFeb 24, 2025

          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.

          • Jesse Warden
            Jesse WardenFeb 24, 2025

            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.

            • Jesse Warden
              Jesse WardenFeb 24, 2025

              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.

              • José Pablo Ramírez Vargas
                José Pablo Ramírez VargasFeb 24, 2025

                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.

                • José Pablo Ramírez Vargas
                  José Pablo Ramírez VargasFeb 24, 2025

                  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.

                  • Jesse Warden
                    Jesse WardenMar 4, 2025

                    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.

                    • José Pablo Ramírez Vargas
                      José Pablo Ramírez VargasMar 4, 2025

                      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.

Add comment