Skip to content

Write an API Client with Test-Driven Development

Published: at 03:59 PM

What is an API client?

An API client is a highly reusable piece of code that simplifies communication between your project and an external API. The primary goal of an API client is to simplify the process of making API requests.

Whenever an external service has an API for you to integrate with, whether it’s a REST API, a GraphQL API, or some other technology, some code will need to be written to communicate between your application and the external API.

Sometimes external services publish their own API client that they maintain. This saves you time because that company’s implementation is probably good enough to be used in your project.

When you are building an API yourself, or you are integrating with an API that does not have an implementation in your programming language, it’s often a good idea to write your own API client.

A small client goes a long way

Spending a little time writing a small API client will prevent a lot of code duplication in your project.

When making requests to external APIs, there are a few tasks that need to be performed for every single request:

How to write a basic API client

Let’s write an API client together, with unit test coverage so we can rely on it in production. I’ll be using a Test-Driven Development approach, where the tests guide the implementation.

An API for blog posts

Let’s say you have the following REST API, representing a blog:

This API will support the following resources:

Our first unit test

An API client needs to do the following things:

Make a request

The simplest first test we can write is that our client can make a request to one of the API’s endpoints.

import { describe, expect, it } from "vitest";

describe("BlogApiClient", () => {
  it("can get all posts", () => {
    const client = new BlogApiClient();

    const response = client.getPosts();

    expect(response.status).toBe(200);
  });
});

We’re not sure at this stage what the interface of the API client should be, but we do know that APIs tend to return an HTTP status code of 200 on a successful GET request, so that is a good enough place to start.

Run the test with yarn test to determine the next step:

FAIL  BlogApi.spec.ts > BlogApiClient > can get all posts
ReferenceError: BlogApiClient is not defined
 ❯ BlogApi.spec.ts:5:20
      3| describe('BlogApiClient', () => {
      4|   it('can get all posts', () => {
      5|     const client = new BlogApiClient()
       |                    ^
      6|
      7|     const response = client.getPosts()

The tests are driving our code. Write just enough code to resolve the test’s error: BlogApiClient is not defined

src/BlogApi/BlogApiClient.ts

export default class BlogApiClient {}

src/BlogApi/BlogApi.spec.ts

import { describe, expect, it } from "vitest";
import BlogApiClient from "./BlogApiClient"; // <--

// ...

Now if you re-run the tests, they encounter a new error. That’s progress!

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get all posts
TypeError: client.getPosts is not a function
 ❯ src/BlogApi/BlogApi.spec.ts:8:29
      6|     const client = new BlogApiClient()
      7|
      8|     const response = client.getPosts()
       |                             ^
      9|
     10|     expect(response.status).toBe(200)

Our tests are telling us that getPosts is not a function. That’s easily solved:

export default class BlogApiClient {
  getPosts() {
    return;
  }
}
FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get all posts
TypeError: Cannot read properties of undefined (reading 'status')
 ❯ src/BlogApi/BlogApi.spec.ts:10:21
      8|     const response = client.getPosts()
      9|
     10|     expect(response.status).toBe(200)
       |                     ^
     11|   })
     12| })

The final piece of this test is a simple addition to the return function of getPosts:

export default class BlogApiClient {
  getPosts() {
    return { status: 200 };
  }
}
✓ src/BlogApi/BlogApi.spec.ts (1)
   ✓ BlogApiClient (1)
     ✓ can get all posts

All tests are passing. At this stage, there’s no duplication anywhere so there isn’t anything to refactor.

We have the start of a client, but its implementation isn’t actually calling fetch. However, we don’t want our tests to make actual API requests, at least not for these unit tests that are testing the API client in isolation.

The next step is to augment our existing test so that it asserts on an actual request being made.

// add a new import: vi
import { describe, expect, it, vi } from "vitest";
import BlogApiClient from "./BlogApiClient";

describe("BlogApiClient", () => {
  it("can get all posts", () => {
    // obtain a spy reference to `fetch` that we can assert on
    const fetcher = vi.spyOn(globalThis, "fetch");

    // stub fetch to return a successful response
    fetcher.mockImplementationOnce(() =>
      Promise.resolve(new Response(null, { status: 200 }))
    );

    const client = new BlogApiClient();

    const response = client.getPosts();

    expect(response.status).toBe(200);
    expect(fetcher).toHaveBeenCalledOnce();
  });
});

Our test is failing now, as-expected:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get all posts
AssertionError: expected "fetch" to be called once, but got 0 times
 ❯ src/BlogApi/BlogApi.spec.ts:19:21
     17|
     18|     expect(response.status).toBe(200)
     19|     expect(fetcher).toHaveBeenCalledOnce()
       |                     ^
     20|   })
     21| })

The tests are driving the code, so if the tests want us to call fetch, the simplest path forward is to call fetch

export default class BlogApiClient {
  getPosts() {
    fetch();
    return { status: 200 };
  }
}

This gets our tests passing:

✓ src/BlogApi/BlogApi.spec.ts (1)
   ✓ BlogApiClient (1)
     ✓ can get all posts

According to Kent Beck in “Test-Driven Development By Example”, the five steps to Test-Driven Development are as-follows:

  1. Write a test.
  2. Make it compile.
  3. Run it to see that it fails.
  4. Make it run.
  5. Remove duplication.

The fifth step is easily misunderstood. Until you’ve reached the fifth step of the process, it’s important to throw away all software design principals and write the simplest code you can think of to move forward. Once your tests are passing, that’s when you revise your implementations and remove the duplication you created.

There’s a clear instance of duplication in the getPosts method: the call to fetch is returning a Response, and the function is also returning an object that impersonates a Response.

We resolve the duplication, then re-run our tests to ensure they are still passing:

export default class BlogApiClient {
  getPosts() {
    return fetch();
  }
}

We have a test failure:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get all posts
AssertionError: expected undefined to be 200 // Object.is equality

- Expected:
200

+ Received:
undefined

 ❯ src/BlogApi/BlogApi.spec.ts:18:29
     16|     const response = client.getPosts()
     17|
     18|     expect(response.status).toBe(200)
       |                             ^
     19|     expect(fetcher).toHaveBeenCalledOnce()
     20|   })

It seems the dummy data we used earlier has outlived its usefulness. Let’s get the test passing once more:

describe("BlogApiClient", () => {
  // start using async/await
  it("can get all posts", async () => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    fetcher.mockImplementationOnce(() =>
      Promise.resolve(new Response(null, { status: 200 }))
    );

    const client = new BlogApiClient();

    // await the returned promise
    const response = await client.getPosts();

    expect(response.status).toBe(200);
    expect(fetcher).toHaveBeenCalledOnce();
  });
});

With that, tests are now passing and we’ve resolved the duplication.

There’s a little more to do before this test is fully implementing the required feature: it needs to make the request to the desired endpoint. Implement this first in your test:

describe("BlogApiClient", () => {
  it("can get all posts", async () => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    // implement a mock fetcher
    fetcher.mockImplementationOnce(async (url, options) => {
      if (!url) throw new Error("No URL was provided to fetch");

      if (typeof url !== "string")
        throw new Error("Mock expects a URL string to be passed into fetch");

      if (url === "https://example.com/posts")
        return new Response(null, { status: 200 });

      return new Response(null, { status: 404 });
    });

    const client = new BlogApiClient();

    const response = await client.getPosts();

    expect(response.status).toBe(200);
    expect(fetcher).toHaveBeenCalledOnce();
    // assert that fetcher was called with the expected endpoint
    expect(fetcher).toHaveBeenCalledWith("https://example.com/posts");
  });
});
FAIL  BlogApi.spec.ts > BlogApiClient > can get all posts
Error: No URL was provided to fetch
 ❯ BlogApi.spec.ts:10:23
      8|     // implement a mock fetcher
      9|     fetcher.mockImplementationOnce((url, options) => {
     10|       if (!url) throw new Error('No URL was provided to fetch')
       |                       ^
     11|
     12|       if (typeof url !== 'string')
 ❯ BlogApiClient.getPosts BlogApiClient.ts:3:12
 ❯ BlogApi.spec.ts:23:35

Our tests are failing because our test isn’t passing a URL. That’s easily fixable:

export default class BlogApiClient {
  getPosts() {
    // add the expected URL
    return fetch("https://example.com/posts");
  }
}

And with that, all tests are passing:

✓ src/BlogApi/BlogApi.spec.ts (1)
   ✓ BlogApiClient (1)
     ✓ can get all posts

Handle errors

Our API client in its current form is contributing very little value. Perhaps all it’s doing is creating a thin abstraction barrier around the string literal URL for a single endpoint.

Let’s move on to the next important role of API clients, handling errors. We’ll start with HTTP errors.

Handling Server Errors (status 500)

We’ll start with a new test:

describe("BlogApiClient", () => {
  // ...

  it("handles and safely returns HTTP errors", async () => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    fetcher.mockImplementationOnce(async () => {
      return new Response(null, { status: 500 });
    });

    const client = new BlogApiClient();

    const result = await client.getPosts();

    expect(result.status).toBe(500);
    expect(result.errorType).toBe("UnknownError");
  });
});

There are many different opinions out there about how errors should be handled. You might throw and error and expect the caller to try/catch. You might take the Go language approach and return a tuple e.g. [Data, null] | [null, Error]. You might take the Rust language approach and return a Result, which is a union of { data: Data, success: true, error?: undefined } | { error: Error, success: false, data?: undefined }. There are many more possible approaches. I like to be reminded to handle errors by TypeScript, which is why I prefer to use Results. You may decide to take a different approach with error handling. If you have never used Results before, give them a try with me.

Here’s what happens when we run the test:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP errors
AssertionError: expected undefined to be 'UnknownError' // Object.is equality

- Expected:
"UnknownError"

+ Received:
undefined

 ❯ src/BlogApi/BlogApi.spec.ts:43:30
     41|
     42|     expect(result.status).toBe(500)
     43|     expect(result.errorType).toBe('UnknownError')
       |                              ^
     44|   })
     45| })

errorType doesn’t exist on the Response object that is returned by fetch. Our new test is expecting the fetch stub’s error response (see: status: 500) to be interpreted as an UnknownError by our API client.

To implement this, we’ll need to refactor the return type of getPosts from simply returning the response to returning a Result.

// implement a Result type
type Result =
  | {
      data: Response;
      status: number;
      error?: undefined;
      errorType?: undefined;
    }
  | {
      data?: undefined;
      status: number;
      error: Error;
      errorType: "UnknownError";
    };

export default class BlogApiClient {
  // use an async function, now that we're handling the
  // return of `fetch` within `getPosts`
  async getPosts(): Promise<Result> {
    const response = await fetch("https://example.com/posts");

    // write only what you need to pass the test
    return {
      status: response.status,
      errorType: "UnknownError",
    };
  }
}

This gets both tests passing

✓ src/BlogApi/BlogApi.spec.ts (2)
   ✓ BlogApiClient (2)
     ✓ can get all posts
     ✓ handles and safely returns HTTP errors

Two things aren’t quite right:

Both of these issues should be resolved, but the order doesn’t particularly matter. We can fix the type error by returning error, but let’s do the due diligence of expecting error to be returned by our tests as well.

describe("BlogApiClient", () => {
  // ...

  it("handles and safely returns HTTP errors", async () => {
    // ...

    expect(result.status).toBe(500);
    // expect an error to be returned
    expect(result.error).toBeInstanceOf(Error);
    expect(result.errorType).toBe("UnknownError");
  });
});

This results in a test failure:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP errors
AssertionError: expected undefined to be an instance of Error
 ❯ src/BlogApi/BlogApi.spec.ts:45:26
     43|
     44|     expect(result.status).toBe(500)
     45|     expect(result.error).toBeInstanceOf(Error)
       |                          ^
     46|     expect(result.errorType).toBe('UnknownError')
     47|   })

Which is resolved by a small change to our client:

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    const response = await fetch("https://example.com/posts");

    return {
      status: response.status,
      // wrap the fetch response in an Error to capture the stack trace
      error: new Error("Encountered an unknown error", { cause: response }),
      errorType: "UnknownError",
    };
  }
}

Tests are passing again. Great!

Now let’s assert that the response was not an error in our first test.

describe("BlogApiClient", () => {
  it("can get all posts", async () => {
    // ...

    const result = await client.getPosts();

    expect(result.status).toBe(200);
    // assert that no error was returned
    expect(result.error).toBeUndefined();
    // assert that no error type was returned
    expect(result.errorType).toBeUndefined();
    expect(fetcher).toHaveBeenCalledOnce();
    expect(fetcher).toHaveBeenCalledWith("https://example.com/posts");
  });

  // ...
});

Now we’re getting a test failure, as-expected:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get all posts
AssertionError: expected Error: Encountered an unknown error { cause: Response{ …(3) } } to be undefined
 ❯ src/BlogApi/BlogApi.spec.ts:26:26
     24|
     25|     expect(result.status).toBe(200)
     26|     expect(result.error).toBeUndefined()
       |                          ^
     27|     expect(result.errorType).toBeUndefined()
     28|     expect(fetcher).toHaveBeenCalledOnce()

Take the simplest possible path towards passing your test:

// ...

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    const response = await fetch("https://example.com/posts");

    // only return an UnknownError if the response status is 500
    if (response.status === 500) {
      return {
        status: response.status,
        error: new Error("Encountered an unknown error", { cause: response }),
        errorType: "UnknownError",
      };
    }

    return {
      status: response.status,
    };
  }
}

This gets our tests passing:

✓ src/BlogApi/BlogApi.spec.ts (2)
   ✓ BlogApiClient (2)
     ✓ can get all posts
     ✓ handles and safely returns HTTP errors

You may be tempted to use response.ok instead of checking the value of response.status. The downside with this approach is that it adds behaviors to your implementation that are not covered by the tests. If response.status were to equal 401, we would return an UnknownError but a 401 is an authentication error. Staying more specific helps insulate you from unexpected behaviors (i.e. bugs).

While our tests are passing, we’ve introduced a type checking error because our success result is missing a data attribute. Let’s augment our tests to expect the same, then fix the type error.

describe("BlogApiClient", () => {
  it("can get all posts", async () => {
    // ...

    const result = await client.getPosts();

    expect(result.status).toBe(200);
    expect(result.error).toBeUndefined();
    expect(result.errorType).toBeUndefined();
    // we'll revisit data later. keep things simiple for now
    expect(result.data).toBeTruthy();
    expect(fetcher).toHaveBeenCalledOnce();
    expect(fetcher).toHaveBeenCalledWith("https://example.com/posts");
  });

  // ...
});
FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get all posts
AssertionError: expected undefined to be truthy

- Expected:
undefined

+ Received:
false

 ❯ src/BlogApi/BlogApi.spec.ts:27:25
     25|     expect(result.error).toBeUndefined()
     26|     expect(result.errorType).toBeUndefined()
     27|     expect(result.data).toBeTruthy()
       |                         ^
     28|     expect(fetcher).toHaveBeenCalledOnce()
     29|     expect(fetcher).toHaveBeenCalledWith('https://example.com/posts')
export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    return {
      // satisfy the type requirements
      data: response,
      status: response.status,
    };
  }
}

Handling Not Found Errors (status 404)

So far, we’ve handled external server errors, but an API client should be able to handle other error responses as well. Let’s add support for 404 Not Found errors.

We can do this simply by copy-pasting our test for 500 errors and modifying a few values:

describe("BlogApiClient", () => {
  // ...

  // modify the test title to add specificity
  it("handles and safely returns HTTP 500 errors", async () => {
    // ...
  });

  // add the relevant status code to the new test
  it("handles and safely returns HTTP 404 errors", async () => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    fetcher.mockImplementationOnce(async () => {
      // stub `fetch` to respond with a 404
      return new Response(null, { status: 404 });
    });

    const client = new BlogApiClient();

    const result = await client.getPosts();

    // expect the 404 to be returned
    expect(result.status).toBe(404);
    expect(result.error).toBeInstanceOf(Error);
    // use a descriptive errorType
    expect(result.errorType).toBe("RequestError");
  });
});

Tests are failing because we’re returning a success Result:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 404 errors
AssertionError: expected undefined to be an instance of Error
 ❯ src/BlogApi/BlogApi.spec.ts:60:26
     58|
     59|     expect(result.status).toBe(404)
     60|     expect(result.error).toBeInstanceOf(Error)
       |                          ^
     61|     expect(result.errorType).toBe('RequestError')
     62|   })

Resolve this assertion only, with the simplest possible solution. Copy-paste is your friend while your tests are failing:

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    if (response.status === 500) {
      return {
        status: response.status,
        error: new Error("Encountered an unknown error", { cause: response }),
        errorType: "UnknownError",
      };
    }

    // copy-paste the conditional statement above
    // make minimal modifications to appease the test failure
    if (response.status === 404) {
      return {
        status: response.status,
        error: new Error("Encountered an unknown error", { cause: response }),
        errorType: "UnknownError",
      };
    }

    // ...
  }
}

By only making the smallest possible change, you can watch your test fail and know that it will fail if your implementation regresses in the future:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 404 errors
AssertionError: expected 'UnknownError' to be 'RequestError' // Object.is equality

- Expected
+ Received

- RequestError
+ UnknownError

 ❯ src/BlogApi/BlogApi.spec.ts:61:30
     59|     expect(result.status).toBe(404)
     60|     expect(result.error).toBeInstanceOf(Error)
     61|     expect(result.errorType).toBe('RequestError')
       |                              ^
     62|   })
     63| })

Our tests tell us exactly what should be done:

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    if (response.status === 404) {
      return {
        status: response.status,
        error: new Error("Encountered an unknown error", { cause: response }),
        errorType: "RequestError",
      };
    }

    // ...
  }
}

We’ll also augment our Result type:

type Result =
  | {
      data: Response;
      status: number;
      error?: undefined;
      errorType?: undefined;
    }
  | {
      data?: undefined;
      status: number;
      error: Error;
      // add a new possibility to this union
      errorType: "UnknownError" | "RequestError";
    };

That gets our tests passing, though we now have some duplication:

Let’s solve the issue with the error message. This is very much a bug, so we will modify our tests to fail because this bug exists:

describe("BlogApiClient", () => {
  // ...

  it("handles and safely returns HTTP 500 errors", async () => {
    // ...

    // expect an error message related to unknown errors
    expect(result.error).toEqual(new Error("Encountered an unknown error"));

    // ...
  });

  it("handles and safely returns HTTP 404 errors", async () => {
    // ...

    // expect an error message related to the request
    expect(result.error).toEqual(
      new Error("Encountered an error with the request")
    );

    // ...
  });
});

Now that we’ve confirmed that our tests cover the bug with our error messages, we can resolve the duplication:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 404 errors
AssertionError: expected Error: Encountered an unknown error { cause: Response{ …(3) } } to deeply equal Error: Encountered an error with the requ…

- Expected
+ Received

- [Error: Encountered an error with the request]
+ [Error: Encountered an unknown error]

 ❯ src/BlogApi/BlogApi.spec.ts:60:26
     58|
     59|     expect(result.status).toBe(404)
     60|     expect(result.error).toEqual(
       |                          ^
     61|       new Error('Encountered an error with the request')
     62|     )
export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    if (response.status === 404) {
      return {
        status: response.status,
        // modify the error message
        error: new Error("Encountered an error with the request", {
          cause: response,
        }),
        errorType: "RequestError",
      };
    }

    // ...
  }
}

Tests are passing. Great!

Now let’s resolve the duplication between our tests. We’ll do this by using what’s sometimes called a “parameterized test” via the it.each method (learn more here).

describe("BlogApiClient", () => {
  it("can get all posts", async () => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    fetcher.mockImplementationOnce(async (url, options) => {
      if (!url) throw new Error("No URL was provided to fetch");

      if (typeof url !== "string")
        throw new Error("Mock expects a URL string to be passed into fetch");

      if (url === "https://example.com/posts")
        return new Response(null, { status: 200 });

      return new Response(null, { status: 404 });
    });

    const client = new BlogApiClient();

    const result = await client.getPosts();

    expect(result.status).toBe(200);
    expect(result.error).toBeUndefined();
    expect(result.errorType).toBeUndefined();
    expect(result.data).toBeTruthy();
    expect(fetcher).toHaveBeenCalledOnce();
    expect(fetcher).toHaveBeenCalledWith("https://example.com/posts");
  });

  // replace your current error handling tests with this
  // parameterized implementation
  it.each([
    [500, "UnknownError", "Encountered an unknown error"],
    [404, "RequestError", "Encountered an error with the request"],
  ])(
    "handles and safely returns HTTP %i errors",
    async (status, errorType, errorMessage) => {
      const fetcher = vi.spyOn(globalThis, "fetch");

      fetcher.mockImplementationOnce(async () => {
        return new Response(null, { status });
      });

      const client = new BlogApiClient();

      const result = await client.getPosts();

      expect(result.status).toBe(status);
      expect(result.error).toEqual(new Error(errorMessage));
      expect(result.errorType).toBe(errorType);
    }
  );

  // Delete the following tests:

  // - it('handles and safely returns HTTP 500 errors', async () => {
  // -   const fetcher = vi.spyOn(globalThis, 'fetch')

  // -   fetcher.mockImplementationOnce(async () => {
  // -     return new Response(null, { status: 500 })
  // -   })

  // -   const client = new BlogApiClient()

  // -   const result = await client.getPosts()

  // -   expect(result.status).toBe(500)
  // -   expect(result.error).toEqual(new Error('Encountered an unknown error'))
  // -   expect(result.errorType).toBe('UnknownError')
  // - })

  // - it('handles and safely returns HTTP 404 errors', async () => {
  // -   const fetcher = vi.spyOn(globalThis, 'fetch')

  // -   fetcher.mockImplementationOnce(async () => {
  // -     return new Response(null, { status: 404 })
  // -   })

  // -   const client = new BlogApiClient()

  // -   const result = await client.getPosts()

  // -   expect(result.status).toBe(404)
  // -   expect(result.error).toEqual(
  // -     new Error('Encountered an error with the request')
  // -   )
  // -   expect(result.errorType).toBe('RequestError')
  // - })
});
export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    if (response.status === 500) {
      return {
        status: response.status,
        error: new Error("Found a gold coin", { cause: response }),
        errorType: "GoldError",
      };
    }

    if (response.status === 404) {
      return {
        status: response.status,
        error: new Error("Found a silver coin", {
          cause: response,
        }),
        errorType: "SilverError",
      };
    }

    // ...
  }
}

With this, tests are passing. Let’s double-check our work by adding a few bugs to our implementation, so we can watch our refactored test fail in expected ways:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 500 errors
AssertionError: expected Error: Found a gold coin { …(1) } to deeply equal Error: Encountered an unknown error

- Expected
+ Received

- [Error: Encountered an unknown error]
+ [Error: Found a gold coin]

// ...

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 404 errors
AssertionError: expected Error: Found a silver coin { …(1) } to deeply equal Error: Encountered an error with the requ…

- Expected
+ Received

- [Error: Encountered an error with the request]
+ [Error: Found a silver coin]

Great! Resolve these test errors to verify your other assertions:

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    if (response.status === 500) {
      return {
        status: response.status,
        error: new Error("Encountered an unknown error", { cause: response }),
        errorType: "GoldError",
      };
    }

    if (response.status === 404) {
      return {
        status: response.status,
        error: new Error("Encountered an error with the request", {
          cause: response,
        }),
        errorType: "SilverError",
      };
    }

    // ...
  }
}
FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 500 errors
AssertionError: expected 'GoldError' to be 'UnknownError' // Object.is equality

- Expected
+ Received

- UnknownError
+ GoldError

// ...

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 404 errors
AssertionError: expected 'SilverError' to be 'RequestError' // Object.is equality

- Expected
+ Received

- RequestError
+ SilverError

The assertions seem to be working! Get your tests passing again and we’ll be ready to move on.

Handling remaining HTTP errors

A common pattern in API clients is to handle HTTP errors in groups rather than handling status codes individually. Some common HTTP errors to handle are:

We can safely say that any status code in the 500 range is an error on the server we’re requesting, and any 400 error is an error with our client. Let’s codify these assumptions in our test:

describe("BlogApiClient", () => {
  // ...

  // Modify the errorTypes and error messages that we expect
  it.each([
    [500, "ServerError", "Received an error from an external resource"],
    [503, "ServerError", "Received an error from an external resource"],
    [400, "ClientError", "Sent a problematic request to an external resource"],
    [401, "ClientError", "Sent a problematic request to an external resource"],
    [403, "ClientError", "Sent a problematic request to an external resource"],
    [429, "ClientError", "Sent a problematic request to an external resource"],
  ])(
    "handles and safely returns HTTP %i errors",
    async (status, errorType, errorMessage) => {
      // ...
    }
  );
});

See how easy it is to add additional test cases when you use parameterized tests?

Modify your implementation to pass all tests:

type Result =
  | {
      data: Response;
      status: number;
      error?: undefined;
      errorType?: undefined;
    }
  | {
      data?: undefined;
      status: number;
      error: Error;
      // use new, higher-level values
      errorType: "ServerError" | "ClientError";
    };

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    const response = await fetch("https://example.com/posts");

    // capture a range of values
    if (response.status >= 500 && response.status < 600) {
      return {
        status: response.status,
        // modify error and errorType
        error: new Error("Received an error from an external resource", {
          cause: response,
        }),
        errorType: "ServerError",
      };
    }

    // capture a range of values
    if (response.status >= 400 && response.status < 500) {
      return {
        status: response.status,
        // modify error and errorType
        error: new Error("Sent a problematic request to an external resource", {
          cause: response,
        }),
        errorType: "ClientError",
      };
    }

    return {
      data: response,
      status: response.status,
    };
  }
}

Handling Request Errors

Did you know that fetch can throw?

When you invoke fetch, numerous validation checks run on the arguments before sending a network request to ensure that valid data is being provided. For example, if you type a space in an HTTP header name then fetch will throw a TypeError.

So far, we’ve handled ClientErrors and ServerErrors. Let’s simulate a thrown error from fetch and expect to receive a RequestError.

describe("BlogApiClient", () => {
  // ...

  it("handles thrown errors from fetch", async () => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    fetcher.mockRejectedValueOnce(new TypeError("Boom!"));

    const client = new BlogApiClient();

    const result = await client.getPosts();

    expect(result.status).toBe(null);
    expect(result.error).toEqual(new Error("Failed to perform a request"));
    expect(result.errorType).toBe("RequestError");
  });
});

The test fails because we’re not catching an error thrown by fetch:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles thrown errors from fetch
TypeError: Boom!
 ❯ src/BlogApi/BlogApi.spec.ts:61:35
     59|     const fetcher = vi.spyOn(globalThis, 'fetch')
     60|
     61|     fetcher.mockRejectedValueOnce(new TypeError('Boom!'))
       |                                   ^
     62|
     63|     const client = new BlogApiClient()

Augment our implementation to pass the test and all assertions. I won’t break down each individual step, so this is a great opportunity to practice implementing each assertion step-by-step. Here’s the result:

type Result =
  | {
      data: Response;
      status: number;
      error?: undefined;
      errorType?: undefined;
    }
  | {
      data?: undefined;
      // make `status` nullable
      status: number | null;
      error: Error;
      // add a new RequestError option
      errorType: "ServerError" | "ClientError" | "RequestError";
    };

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // wrap the call to `fetch` in a try/catch
    let response: Response;
    try {
      response = await fetch("https://example.com/posts");
    } catch (err) {
      // return a RequestError if any error is thrown by fetch
      return {
        status: null,
        error: new Error("Failed to perform a request", {
          cause: err,
        }),
        errorType: "RequestError",
      };
    }

    // ...
  }
}

We now have passing tests, so this is a good opportunity to check if we have any refactoring opportunities. It occurs to me that status on Result has outlived its usefulness. It is duplicative of the the data in Error.cause and is nullable for unclear reasons when a RequestError is returned. Let’s remove it.

Change the tests first by removing all three assertions on result.status:

Now that your tests don’t depend on result.status being returned, you can refactor your implementation to remove its usage.

Let’s change the Result type first:

type Result =
  | {
      data: Response;
      // delete this line
      // -  status: number
      error?: undefined;
      errorType?: undefined;
    }
  | {
      data?: undefined;
      // delete this line
      // -  status: number | null
      error: Error;
      errorType: "ServerError" | "ClientError" | "RequestError";
    };

Now TypeScript creates a “todo list” for us. We need to delete lines 20, 30, 40, and 50:

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    let response: Response;
    try {
      response = await fetch("https://example.com/posts");
    } catch (err) {
      return {
        // -    status: null,
        error: new Error("Failed to perform a request", {
          cause: err,
        }),
        errorType: "RequestError",
      };
    }

    if (response.status >= 500 && response.status < 600) {
      return {
        // -    status: response.status,
        error: new Error("Received an error from an external resource", {
          cause: response,
        }),
        errorType: "ServerError",
      };
    }

    if (response.status >= 400 && response.status < 500) {
      return {
        // -    status: response.status,
        error: new Error("Sent a problematic request to an external resource", {
          cause: response,
        }),
        errorType: "ClientError",
      };
    }

    return {
      data: response,
      // -  status: response.status,
    };
  }
}

Once those lines are deleted, your tests should still be passing. It’s always a good idea to find opportunities to reduce complexity in your implementation while tests are passing.

Parse the Response

We’ve gotten this far without doing anything with the actual data returned by the API we’re calling. I hope that by now, the value of an API client is becoming more clear. There is a lot of complexity when you’re making network requests.

Let’s break down what we’re going to do with the response data:

  1. Read the data from the response It isn’t enough to simply call response.json()
  2. Validate that the data is in the form that we expect We should be aware immediately if an external API changes
  3. Populate objects that we own, using data from the external API Your project will be more flexible, if it is reasonably decoupled from your dependencies

Read the response data

Did you know that response.json() can throw?

Imagine the following scenario: an external API introduces a bug that causes the unquoted text Server Error to be returned as text in API requests, but still maintains a 200 status. Unlikely, but possible. If any consumers extract the response data via response.json(), the consumer’s code will throw a TypeError which may bubble up to the top of the consumer’s app and crash the consumer too.

Let’s start working on tests for reading the response data and make sure we include one that simulates invalid JSON sent in the response.

import { describe, expect, it, vi } from "vitest";
import BlogApiClient from "./BlogApiClient";

describe("BlogApiClient", () => {
  it("can get all posts", async () => {
    // ...

    fetcher.mockImplementationOnce(async (url, options) => {
      // ...

      if (url === "https://example.com/posts")
        // provide an empty array of blog posts as the response
        return new Response("[]", { status: 200 });

      // ...
    });

    // ...

    expect(result.error).toBeUndefined();
    expect(result.errorType).toBeUndefined();
    // modify our assertion on data to expect a JSON-parsed array
    expect(result.data).toEqual([]);
    expect(fetcher).toHaveBeenCalledOnce();
    expect(fetcher).toHaveBeenCalledWith("https://example.com/posts");
  });

  // ...
});

You’ll receive a test failure that the expected empty array is not equal to the Response object that you received.

Modify your type and your implementation to read JSON data from the Response, rather than returning it.

type Result =
  | {
      // start conservatively with `unknown`. we'll iterate on this soon.
      data: unknown;
      error?: undefined;
      errorType?: undefined;
    }
  | {
      data?: undefined;
      error: Error;
      errorType: "ServerError" | "ClientError" | "RequestError";
    };

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    return {
      // parse the JSON from the response
      data: await response.json(),
    };
  }
}

This gets tests passing. Let’s immediately break them!

describe("BlogApiClient", () => {
  // ...

  it("handles JSON parse errors in the response", async () => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    // notice the simulated external API bug here: the server
    // responds with 200 but doesn't send valid JSON
    fetcher.mockResolvedValueOnce(
      new Response("Server error", { status: 200 })
    );

    const client = new BlogApiClient();

    const result = await client.getPosts();

    // use context-specific error messaging and a new `TypeError` errorType
    expect(result.error).toEqual(new Error("Failed to parse valid JSON"));
    expect(result.errorType).toBe("TypeError");
  });

  // ...
});

The test fails as we’d expect:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles JSON parse errors in the response
SyntaxError: Unexpected token S in JSON at position 0
 ❯ AsyncResource.runInAsyncScope node:async_hooks:203:9

Chances are that you’ve seen an error like this before: SyntaxError: Unexpected token _ in JSON at position _. It makes sense that JSON parsing can throw but it’s not easy to remember to handle this case when you’re integrating with external APIs.

Adding a try/catch will pass the test:

type Result =
  | {
      data: unknown;
      error?: undefined;
      errorType?: undefined;
    }
  | {
      data?: undefined;
      error: Error;
      // add a new errorType
      errorType: "ServerError" | "ClientError" | "RequestError" | "TypeError";
    };

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    try {
      // wrap JSON parsing in a `try`
      return {
        data: await response.json(),
      };
    } catch (err) {
      // return our expected error in the `catch`
      return {
        error: new Error("Failed to parse valid JSON", { cause: err }),
        errorType: "TypeError",
      };
    }
  }
}

Validate the data we’ve received

By this point, you’ve probably realized that we’re programming very defensively. The goal is to prevent other people’s bugs from becoming our bugs as much as we possibly can.

In order to validate incoming unknown data, it’s really helpful to have some types in-place. Let’s define some types based on the API documentation for this blog API. Here’s a reminder, so you don’t have to scroll up:

We only need Post for now, but we’ll get to the Comment resource soon enough.

Start with the test. We’ll refactor our “happy path” test to use real data:

describe("BlogApiClient", () => {
  it("can get all posts", async () => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    // create some fixture data with a type that we haven't written yet
    const blogPostFixture: Post = {
      id: "foo",
      createdAt: "1843-12-12T14:48:00.000Z",
      content: "Hello, world",
      author: "Ada Lovelace",
    };

    fetcher.mockImplementationOnce(async (url, options) => {
      if (!url) throw new Error("No URL was provided to fetch");

      if (typeof url !== "string")
        throw new Error("Mock expects a URL string to be passed into fetch");

      if (url === "https://example.com/posts")
        // mock the return of the expected data
        return new Response(JSON.stringify([blogPostFixture]), {
          status: 200,
        });

      return new Response(null, { status: 404 });
    });

    const client = new BlogApiClient();

    const result = await client.getPosts();

    expect(result.error).toBeUndefined();
    expect(result.errorType).toBeUndefined();
    // modify your assertion to expect the data to return
    expect(result.data).toEqual([blogPostFixture]);
    expect(fetcher).toHaveBeenCalledOnce();
    expect(fetcher).toHaveBeenCalledWith("https://example.com/posts");
  });

  // ...
});

This test passes.

Now add a test that expects a TypeError to be returned if the data we receive is invalid:

describe("BlogApiClient", () => {
  // ...

  it("returns an error if malformed data is returned", async () => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    fetcher.mockResolvedValueOnce(
      // return an array with an incomplete Post
      new Response(JSON.stringify([{ id: 1234 }]), { status: 200 })
    );

    const client = new BlogApiClient();

    const result = await client.getPosts();

    // use an error that's specific to this situation
    expect(result.error).toEqual(new Error("Received malformed Post data"));
    expect(result.errorType).toBe("TypeError");
  });

  // ...
});

The test fails because we’re accepting any valid JSON that the API happens to respond with:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > returns an error if malformed data is returned
AssertionError: expected undefined to deeply equal Error: Received malformed Post data

- Expected:
[Error: Received malformed Post data]

+ Received:
undefined

We’ll begin the implementation by creating a type to represent a Post:

type Result =
  | {
      data: unknown;
      error?: undefined;
      errorType?: undefined;
    }
  | {
      data?: undefined;
      error: Error;
      errorType: "ServerError" | "ClientError" | "RequestError" | "TypeError";
    };

/**
 * Post represents a blog post and its content
 */
export type Post = {
  id: string;
  createdAt: string;
  content: string;
  author: string;
};

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...
  }
}

You may choose to import it into your test file as well:

import { describe, expect, it, vi } from "vitest";
// add the new import
import BlogApiClient, { Post } from "./BlogApiClient";

describe("BlogAPiClient", () => {
  // ...
});

Let’s check to make sure that all of our expected attributes are defined:

// ...

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    try {
      const posts = await response.json();
      // check each post returned, return early if any of them are invalid
      for (const post of posts) {
        if (!post.id || !post.createdAt || !post.content || !post.author) {
          return {
            error: new Error("Received malformed Post data", { cause: posts }),
            errorType: "TypeError",
          };
        }
      }
      return {
        data: posts,
      };
    } catch (err) {
      // ...
    }
  }
}

Now that we have a passing test that provides invalid data, let’s refactor our implementation to use a library. We’ll use Zod to dramatically improve the reach of our validation and get a type automatically generated for us.

We’ll need to install the package. See this npm link for the latest installation information: https://www.npmjs.com/package/zod

Once installed, let’s build a schema for the Post resource:

// this is how you import zod into your project
import { z } from "zod";

// ...

// a zod schema can be used to validate data
// note that zod types are required by default
const postSchema = z.object({
  id: z.string(),
  createdAt: z.string().datetime(),
  content: z.string(),
  author: z.string(),
});

// use zod to infer the type of Post from the schema
/**
 * Post represents a blog post and its content
 */
export type Post = z.infer<typeof postSchema>;

export default class BlogApiClient {
  // ...
}

Now that you have a schema, use it to validate the data in the response:

// ...

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    try {
      const posts = await response.json();

      // use z.array to extend your schema to parse an array of posts
      // then use `safeParse` to validate the data without throwing an error
      const parseResult = z.array(postSchema).safeParse(posts);

      if (!parseResult.success) {
        return {
          error: new Error("Received malformed Post data", {
            cause: parseResult.error.issues,
          }),
          errorType: "TypeError",
        };
      }

      return {
        data: parseResult.data,
      };
    } catch (err) {
      // ...
    }
  }
}

Populate objects we own

Wait, we’re not done?

It would be reasonable to think that we’re done with the /posts endpoint. We are fetching the data with extensive error handling and ensuring that the data we receive is in the form that we expect. Surely now we can start implementing our feature right?

Say six months go by. Maybe two years. You’ve put some serious time into your project and built numerous features on top of the data you’re receiving from an external API. Then something unexpected happens. Take your pick:

Consider even these possible scenarios:

In both the scary and happy scenarios, the result is the same: it’s time to migrate off of the external API and onto a new one.

But what happens if all of your features, your entire project, depends on the data that’s being returned from this current API? Suddenly you are faced with a refactor of the entire codebase, which could be expensive enough that the return on investment of the refactor isn’t high enough to migrate away from the API.

Let’s talk about a solution for how to improve your chances of a quick and painless API migration.

Turn their Post into your Post

We’re going to take the raw data returned by the external blog post API (i.e. their Post) and use it to construct a class that lives within our codebase.

For this, we’ll create two new files:

As always, we’ll start with a test in Post.spec.ts:

import { describe, it, expect } from "vitest";
import type * as BlogApi from "../BlogApi/BlogApiClient";
import Post from "./Post";

describe("Post", () => {
  it("can be constructed from a BlogApiPost", () => {
    const apiPost: BlogApi.Post = {
      id: "foo",
      createdAt: "1843-12-12T14:48:00.000Z",
      content: "Hello, world",
      author: "Ada Lovelace",
    };

    const post = new Post(apiPost);

    expect(post.id).toEqual(apiPost.id);
    expect(post.createdAt).toEqual(new Date(apiPost.createdAt));
    expect(post.content).toEqual(apiPost.content);
    expect(post.author).toEqual(apiPost.author);
  });
});

And our test fails because Post doesn’t exist:

FAIL  src/domain/Post.spec.ts > Post > can be constructed from a BlogApiPost
TypeError: default is not a constructor
 ❯ src/domain/Post.spec.ts:14:18
     12|     }
     13|
     14|     const post = new Post(apiPost)
       |                  ^
     15|
     16|     expect(post.id).toEqual(apiPost.id)

We know the drill here: do what the test says to do. Add a class to Post.ts:

export default class Post {}

And we have a new test failure:

FAIL  src/domain/Post.spec.ts > Post > can be constructed from a BlogApiPost
AssertionError: expected undefined to deeply equal 'foo'

- Expected:
"foo"

+ Received:
undefined

 ❯ src/domain/Post.spec.ts:16:21
     14|     const post = new Post(apiPost)
     15|
     16|     expect(post.id).toEqual(apiPost.id)
       |                     ^
     17|     expect(post.createdAt).toEqual(new Date(apiPost.createdAt))
     18|     expect(post.content).toEqual(apiPost.content)

Add a property for id to make progress:

import type * as BlogApi from "../BlogApi/BlogApiClient";

export default class Post {
  id: string;

  constructor(post: BlogApi.Post) {
    this.id = post.id;
  }
}

And repeat the process for the remaining attributes:

import type * as BlogApi from "../BlogApi/BlogApiClient";

export default class Post {
  id: string;
  createdAt: Date;
  content: string;
  author: string;

  constructor(post: BlogApi.Post) {
    this.id = post.id;
    this.createdAt = new Date(post.createdAt);
    this.content = post.content;
    this.author = post.author;
  }
}

Let’s finish this by adding it to our API client. Start with the tests:

import { describe, expect, it, vi } from "vitest";
// alias the type import here to prefer our implementation
import BlogApiClient, { Post as BlogAPiPost } from "./BlogApiClient";
// import our domain class
import Post from "../domain/Post";

describe("BlogApiClient", () => {
  it("can get all posts", async () => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    // update this type to use the aliased name
    const blogPostFixture: BlogAPiPost = {
      id: "foo",
      createdAt: "1843-12-12T14:48:00.000Z",
      content: "Hello, world",
      author: "Ada Lovelace",
    };

    // ...

    expect(result.error).toBeUndefined();
    expect(result.errorType).toBeUndefined();
    // first, make sure we're working with the expected types
    for (const post of result.data) {
      expect(post).toBeInstanceOf(Post);
    }
    // then ensure that the constructor was called correctly
    expect(result.data).toEqual([new Post(blogPostFixture)]);
    expect(fetcher).toHaveBeenCalledOnce();
    expect(fetcher).toHaveBeenCalledWith("https://example.com/posts");
  });
  // ...
});

The test fails because we’re returning object literals:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get all posts
AssertionError: expected { id: 'foo', …(3) } to be an instance of Post
 ❯ src/BlogApi/BlogApi.spec.ts:37:20
     35|     expect(result.errorType).toBeUndefined()
     36|     for (const post of result.data) {
     37|       expect(post).toBeInstanceOf(Post)
       |                    ^
     38|     }
     39|     expect(result.data).toEqual([new Post(blogPostFixture)])

And we can get it passing by constructing Posts:

import { z } from "zod";
// import our domain class with an alias that won't collide
import DomainPost from "../domain/Post";

type Result =
  | {
      // address the type error in the tests
      data: Post[];
      error?: undefined;
      errorType?: undefined;
    }
  | {
      data?: undefined;
      error: Error;
      errorType: "ServerError" | "ClientError" | "RequestError" | "TypeError";
    };

// ...

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    try {
      // ...

      return {
        // map the data into the constructor
        data: parseResult.data.map(apiPost => new DomainPost(apiPost)),
      };
    } catch (err) {
      // ...
    }
  }
}

We still have one more type error. At the time of this writing, Vitest’s assertions don’t assert on the types, so even though we’re asserting that result.error === undefined in expect(result.error).toBeUndefined(), TypeScript is uninformed of this and wants us to narrow the result before we access result.data.

In situations like this, I prefer to use a small helper to give TypeScript the information it needs:

import { describe, expect, it, vi } from "vitest";
import BlogApiClient, { Post as BlogAPiPost, Result } from "./BlogApiClient";
import Post from "../domain/Post";

describe("BlogApiClient", () => {
  it("can get all posts", async () => {
    // ...

    // call our new helper
    assertResultSuccess(result);
    expect(result.errorType).toBeUndefined();
    for (const post of result.data) {
      expect(post).toBeInstanceOf(Post);
    }
    expect(result.data).toEqual([new Post(blogPostFixture)]);
    expect(fetcher).toHaveBeenCalledOnce();
    expect(fetcher).toHaveBeenCalledWith("https://example.com/posts");
  });

  // ...
});

// implement a helper for adding runtime type awareness to our assertion
function assertResultSuccess(
  result: { error: Error } | { error: undefined }
): asserts result is { error: undefined } {
  expect(result.error).toBeUndefined();
}

That resolves the type error and allows us to access result.data without complaint.

There’s some tricky TypeScript going on in assertResultSuccess.

First, notice that result doesn’t reference our Result type directly. Instead, it represents a union of partial representations of Result. Given that TypeScript is a structural typing system, this is totally fair-game. You can supply an object with more than the required attributes to a given function.

Second, notice the asserts keyword. This is a TypeScript keyword that informs TypeScript that you are doing runtime type checking. When you specify that a function asserts a condition, the function should throw if that condition is false. In this case, expect().toBeUndefined() will throw if anything but undefined is provided to it, so asserts is perfectly appropriate here. That said, be careful when reaching for runtime type checking. You are writing code that cannot be statically verified by the type checker.

Whew, let’s recap

We’ve gotten a lot done:

Support the next endpoint

We’ve implemented the /posts endpoint. Now let’s add support for the /posts/{postId}/comments endpoint. As always, we’re going to exercise restraint here and wait to refactor until we have passing tests.

By this point, we’re familiar with the design patterns and goals of our API client, so we can write out a full test before we start on the implementation. Even though we’re writing the test out in-advance, we’ll check ourselves and watch our assertions fail.

// ...
// update the import, it's fine that we haven't implemented it yet
import BlogApiClient, {
  Post as BlogAPiPost,
  Comment as BlogApiComment,
} from "./BlogApiClient";

// ...

describe("BlogApiClient", () => {
  // ...

  // author a new test
  it("can get comments on a post", async () => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    const postId = "1234abcd";

    const commentsFixture: BlogApiComment = {
      id: "foo",
      postId: "bar",
      createdAt: "1843-12-12T14:48:00.000Z",
      content: "Hello, world",
      author: "Ada Lovelace",
    };

    fetcher.mockImplementationOnce(async (url, options) => {
      if (!url) throw new Error("No URL was provided to fetch");

      if (typeof url !== "string")
        throw new Error("Mock expects a URL string to be passed into fetch");

      if (url === `https://example.com/posts/${postId}/comments`)
        return new Response(JSON.stringify([commentsFixture]), {
          status: 200,
        });

      return new Response(null, { status: 404 });
    });

    const client = new BlogApiClient();

    const result = await client.getComments(postId);

    assertResultSuccess(result);
    expect(result.errorType).toBeUndefined();
    for (const comment of result.data) {
      expect(comment).toBeInstanceOf(Comment);
    }
    expect(result.data).toEqual([new Comment(commentsFixture)]);
    expect(fetcher).toHaveBeenCalledOnce();
    expect(fetcher).toHaveBeenCalledWith(
      `https://example.com/posts/${postId}/comments`
    );
  });

  // ...
});

// ...

We have our first failure:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get comments on a post
TypeError: client.getComments is not a function
 ❯ src/BlogApi/BlogApi.spec.ts:73:33
     71|     const client = new BlogApiClient()
     72|
     73|     const result = await client.getComments(postId)
       |                                 ^
     74|
     75|     assertResultSuccess(result)

This is all we need to move on:

// ...

export default class BlogApiClient {
  // ...

  // declare a new method for the comments endpoint
  async getComments() {}
}

Onto the next assertion:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get comments on a post
TypeError: Cannot read properties of undefined (reading 'error')
 ❯ assertResultSuccess src/BlogApi/BlogApi.spec.ts:155:17
    153|   result: { error: Error } | { error: undefined }
    154| ): asserts result is { error: undefined } {
    155|   expect(result.error).toBeUndefined()
       |                 ^
    156| }
    157|
 ❯ src/BlogApi/BlogApi.spec.ts:75:5

Slow and steady:

// ...

export default class BlogApiClient {
  // ...

  async getComments() {
    return {
      // do what the test tells us to do
      error: undefined,
    };
  }
}

Our new error is:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get comments on a post
TypeError: result.data is not iterable
 ❯ src/BlogApi/BlogApi.spec.ts:77:31
     75|     assertResultSuccess(result)
     76|     expect(result.errorType).toBeUndefined()
     77|     for (const comment of result.data) {
       |                               ^
     78|       expect(comment).toBeInstanceOf(Comment)
     79|     }
// ...

export default class BlogApiClient {
  // ...

  async getComments() {
    return {
      error: undefined,
      // send back something iterable
      data: [],
    };
  }
}

Now we need to pivot to the Comment domain class:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get comments on a post
ReferenceError: Comment is not defined
 ❯ src/BlogApi/BlogApi.spec.ts:80:38
     78|       expect(comment).toBeInstanceOf(Comment)
     79|     }
     80|     expect(result.data).toEqual([new Comment(commentsFixture)])
       |                                      ^
     81|     expect(fetcher).toHaveBeenCalledOnce()
     82|     expect(fetcher).toHaveBeenCalledWith('https://example.com/posts')

Create a new src/domain/Comment.spec.ts as you did with Post:

import { describe, it, expect } from "vitest";
import type * as BlogApi from "../BlogApi/BlogApiClient";
import Comment from "./Comment";

describe("Comment", () => {
  it("can be constructed from a Comment", () => {
    const apiComment: BlogApi.Comment = {
      id: "foo",
      postId: "bar",
      createdAt: "1843-12-12T14:48:00.000Z",
      content: "Hello, world",
      author: "Ada Lovelace",
    };

    const comment = new Comment(apiComment);

    expect(comment.id).toEqual(apiComment.id);
    expect(comment.postId).toEqual(apiComment.postId);
    expect(comment.createdAt).toEqual(new Date(apiComment.createdAt));
    expect(comment.content).toEqual(apiComment.content);
    expect(comment.author).toEqual(apiComment.author);
  });
});

Run your tests:

FAIL  src/domain/Comment.spec.ts > Comment > can be constructed from a Comment
ReferenceError: Comment is not defined
 ❯ src/domain/Comment.spec.ts:15:21
     13|     }
     14|
     15|     const comment = new Comment(apiComment)
       |                     ^
     16|
     17|     expect(comment.id).toEqual(apiComment.id)

And get them passing:

import type * as BlogApi from "../BlogApi/BlogApiClient";

export default class Comment {
  id: string;
  postId: string;
  createdAt: Date;
  content: string;
  author: string;

  constructor(comment: BlogApi.Comment) {
    this.id = comment.id;
    this.postId = comment.postId;
    this.createdAt = new Date(comment.createdAt);
    this.content = comment.content;
    this.author = comment.author;
  }
}
✓ src/domain/Comment.spec.ts (1)
   ✓ Comment (1)
     ✓ can be constructed from a Comment

Now that we have a Comment domain type, we import it to our API tests and get them moving:

import { describe, expect, it, vi } from "vitest";
import Post from "../domain/Post";
// simply importing the class gets us to the next assertion error
import Comment from "../domain/Comment";
import BlogApiClient, {
  Post as BlogAPiPost,
  Comment as BlogApiComment,
} from "./BlogApiClient";

// ...

We have a test failure that’s going to require some amount of implementation code:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > can get comments on a post
AssertionError: expected [] to deeply equal [ Comment{ id: 'foo', …(4) } ]

- Expected
+ Received

- Array [
-   Comment {
-     "author": "Ada Lovelace",
-     "content": "Hello, world",
-     "createdAt": 1843-12-12T14:48:00.000Z,
-     "id": "foo",
-     "poostId": "bar",
-   },
- ]
+ Array []

 ❯ src/BlogApi/BlogApi.spec.ts:84:25
     82|       expect(comment).toBeInstanceOf(Comment)
     83|     }
     84|     expect(result.data).toEqual([new Comment(c…
       |                         ^
     85|     expect(fetcher).toHaveBeenCalledOnce()
     86|     expect(fetcher).toHaveBeenCalledWith(

You can gradually implement your getComments method, taking it one step at a time and letting your tests guide you. Here are the steps I took:

  1. Call fetch with no arguments, pass response.json() to data in the result
  2. Add the expected URL to the fetch call
  3. Forget that I also need to await the call to response.json(), add the await
  4. Import Comment, casted to DomainComment and map the response data into the DomainComment constructor
  5. ✅ Bask in the glory of passing tests

Error Handling Redux

One look at our implementation code makes it clear that we’re not done. We have a massive function with robust error handling on getPosts and no error handling to speak of in getComments. We’re going to fix that, and we’re going to do it by starting with the tests.

API method as a test parameter

We’re going to use it.each to run our tests for each of our endpoints. This will show very clearly where we need to add error handling.

// ...

describe("BlogApiClient", () => {
  // ...

  it.each([
    ["getPosts", []],
    ["getComments", [1234]],
  ])(
    "returns an error if malformed data is returned for %s",
    async (method, args) => {
      const fetcher = vi.spyOn(globalThis, "fetch");

      fetcher.mockResolvedValueOnce(
        new Response(JSON.stringify([{ id: 1234 }]), { status: 200 })
      );

      const client = new BlogApiClient();

      const result = await client[method](...args);

      expect(result.error).toEqual(new Error("Received malformed Post data"));
      expect(result.errorType).toBe("TypeError");
    }
  );

  it.each([
    ["getPosts", []],
    ["getComments", [1234]],
  ])(
    "handles JSON parse errors in the response for %s",
    async (method, args) => {
      const fetcher = vi.spyOn(globalThis, "fetch");

      fetcher.mockResolvedValueOnce(
        new Response("Server error", { status: 200 })
      );

      const client = new BlogApiClient();

      const result = await client[method](...args);

      expect(result.error).toEqual(new Error("Failed to parse valid JSON"));
      expect(result.errorType).toBe("TypeError");
    }
  );

  it.each(
    [
      ["getPosts", []],
      ["getComments", [1234]],
    ].flatMap(endpoint => [
      [
        500,
        ...endpoint,
        "ServerError",
        "Received an error from an external resource",
      ],
      [
        503,
        ...endpoint,
        "ServerError",
        "Received an error from an external resource",
      ],
      [
        400,
        ...endpoint,
        "ClientError",
        "Sent a problematic request to an external resource",
      ],
      [
        401,
        ...endpoint,
        "ClientError",
        "Sent a problematic request to an external resource",
      ],
      [
        403,
        ...endpoint,
        "ClientError",
        "Sent a problematic request to an external resource",
      ],
      [
        429,
        ...endpoint,
        "ClientError",
        "Sent a problematic request to an external resource",
      ],
    ])
  )(
    "handles and safely returns HTTP %i errors for %s",
    async (status, method, args, errorType, errorMessage) => {
      const fetcher = vi.spyOn(globalThis, "fetch");

      fetcher.mockImplementationOnce(async () => {
        return new Response(null, { status });
      });

      const client = new BlogApiClient();

      const result = await client[method](...args);

      expect(result.error).toEqual(new Error(errorMessage));
      expect(result.errorType).toBe(errorType);
    }
  );

  it.each([
    ["getPosts", []],
    ["getComments", [1234]],
  ])("handles thrown errors from fetch for %s", async (method, args) => {
    const fetcher = vi.spyOn(globalThis, "fetch");

    fetcher.mockRejectedValueOnce(new TypeError("Boom!"));

    const client = new BlogApiClient();

    const result = await client[method](...args);

    expect(result.error).toEqual(new Error("Failed to perform a request"));
    expect(result.errorType).toBe("RequestError");
  });
});

// ...

I’ve left in the entirety of each test implementation, but only three things were changed for each of the affected tests:

  1. it.each is used to parameterize the test
  2. the test name was updated to include the endpoint
  3. the invocation of the API client was made configurable

If we look through the massive block of new errors, the failing assertions all look very familiar. Unhandled errors, JSON parse errors, etc.:

FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > returns an error if malformed data is returned for getComments
AssertionError: expected undefined to deeply equal Error: Received malformed Post data

- Expected:
[Error: Received malformed Post data]

+ Received:
undefined

 ❯ src/BlogApi/BlogApi.spec.ts:107:28
    105|       const result = await client[method](...args)
    106|
    107|       expect(result.error).toEqual(new Error('Received malformed Po…
       |                            ^
    108|       expect(result.errorType).toBe('TypeError')
    109|     }

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles JSON parse errors in the response for getComments
SyntaxError: Unexpected token S in JSON at position 0
 ❯ AsyncResource.runInAsyncScope node:async_hooks:203:9

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 500 errors for getComments
 FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 503 errors for getComments
 FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 400 errors for getComments
 FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 401 errors for getComments
 FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 403 errors for getComments
 FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles and safely returns HTTP 429 errors for getComments
SyntaxError: Unexpected end of JSON input
 ❯ BlogApiClient.getComments src/BlogApi/BlogApiClient.ts:90:29
     88|     return {
     89|       error: undefined,
     90|       data: (await response.json()).map(
       |                             ^
     91|         (comment) => new DomainComment(comment)
     92|       ),
 ❯ src/BlogApi/BlogApi.spec.ts:186:22

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/BlogApi/BlogApi.spec.ts > BlogApiClient > handles thrown errors from fetch for getComments
TypeError: Boom!
 ❯ src/BlogApi/BlogApi.spec.ts:199:35
    197|     const fetcher = vi.spyOn(globalThis, 'fetch')
    198|
    199|     fetcher.mockRejectedValueOnce(new TypeError('Boom!'))
       |                                   ^
    200|
    201|     const client = new BlogApiClient()

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 Test Files  1 failed (1)
      Tests  9 failed | 11 passed (20)
   Start at  18:48:23
   Duration  42ms

We have some failing tests. How can we get them passing as quickly as possible?

The answer is pretty much always “clone and clobber”:

// ...

// clone and clobber the postSchema
const commentSchema = z.object({
  id: z.string(),
  postId: z.string(),
  createdAt: z.string().datetime(),
  content: z.string(),
  author: z.string(),
});

/**
 * Comment represents a comment on a blog post
 */
export type Comment = z.infer<typeof commentSchema>;

export default class BlogApiClient {
  async getPosts(): Promise<Result> {
    // ...

    try {
      // ...

      if (!parseResult.success) {
        return {
          // generalize this error message
          error: new Error("Received malformed data", {
            cause: parseResult.error.issues,
          }),
          errorType: "TypeError",
        };
      }

      // ...
    } catch (err) {
      // ...
    }
  }

  // clone and clobber the getPosts method
  async getComments(postId: number) {
    let response: Response;
    try {
      // modify the URL
      response = await fetch(`https://example.com/posts/${postId}/comments`);
    } catch (err) {
      return {
        error: new Error("Failed to perform a request", {
          cause: err,
        }),
        errorType: "RequestError",
      };
    }

    if (response.status >= 500 && response.status < 600) {
      return {
        error: new Error("Received an error from an external resource", {
          cause: response,
        }),
        errorType: "ServerError",
      };
    }

    if (response.status >= 400 && response.status < 500) {
      return {
        error: new Error("Sent a problematic request to an external resource", {
          cause: response,
        }),
        errorType: "ClientError",
      };
    }

    try {
      const posts = await response.json();

      // use the new commentSchema
      const parseResult = z.array(commentSchema).safeParse(posts);

      if (!parseResult.success) {
        return {
          // use the generalized error message
          error: new Error("Received malformed data", {
            cause: parseResult.error.issues,
          }),
          errorType: "TypeError",
        };
      }

      return {
        data: parseResult.data.map(
          // use the DomainComment
          apiComment => new DomainComment(apiComment)
        ),
      };
    } catch (err) {
      return {
        error: new Error("Failed to parse valid JSON", { cause: err }),
        errorType: "TypeError",
      };
    }
  }
}

Hey! Green tests!

✓ src/BlogApi/BlogApi.spec.ts (20)
   ✓ BlogApiClient (20)
     ✓ can get all posts
     ✓ can get comments on a post
     ✓ returns an error if malformed data is returned for getPosts
     ✓ returns an error if malformed data is returned for getComments
     ✓ handles JSON parse errors in the response for getPosts
     ✓ handles JSON parse errors in the response for getComments
     ✓ handles and safely returns HTTP 500 errors for getPosts
     ✓ handles and safely returns HTTP 503 errors for getPosts
     ✓ handles and safely returns HTTP 400 errors for getPosts
     ✓ handles and safely returns HTTP 401 errors for getPosts
     ✓ handles and safely returns HTTP 403 errors for getPosts
     ✓ handles and safely returns HTTP 429 errors for getPosts
     ✓ handles and safely returns HTTP 500 errors for getComments
     ✓ handles and safely returns HTTP 503 errors for getComments
     ✓ handles and safely returns HTTP 400 errors for getComments
     ✓ handles and safely returns HTTP 401 errors for getComments
     ✓ handles and safely returns HTTP 403 errors for getComments
     ✓ handles and safely returns HTTP 429 errors for getComments
     ✓ handles thrown errors from fetch for getPosts
     ✓ handles thrown errors from fetch for getComments

How long did that take you? It took me about five minutes. That’s five minutes of reckless, irresponsible copy-pasting but the result is a suite of passing tests.

Now let’s have some fun and write some code we can be proud of.

This is the process I took:

  1. Extract to method on all error results
  2. Extract to method on the contents of the final try block in getPosts and getComments
  3. Use named types for each union item in Result, to allow Oks to be differentiated from Errs
  4. Extract to method on the entire contents of getComments, to a method that accepts the URL called callApi
  5. Iterate on callApi until tests were passing, by accepting more parameters
  6. Declare myself done

Here’s my finished result with some comments for color:

import { z } from "zod";
import DomainComment from "../domain/Comment";
import DomainPost from "../domain/Post";

type Ok<T> = {
  data: T;
  error?: undefined;
  errorType?: undefined;
};

type Err = {
  data?: undefined;
  error: Error;
  errorType: "ServerError" | "ClientError" | "RequestError" | "TypeError";
};

// splitting Result into Ok and Err allows me to reference the Err
// type in functions that don't need to be aware of what the Ok contains
export type Result<T> = Ok<T> | Err;

const postSchema = z.object({
  id: z.string(),
  createdAt: z.string().datetime(),
  content: z.string(),
  author: z.string(),
});

/**
 * Post represents a blog post and its content
 */
export type Post = z.infer<typeof postSchema>;

const commentSchema = z.object({
  id: z.string(),
  postId: z.string(),
  createdAt: z.string().datetime(),
  content: z.string(),
  author: z.string(),
});

/**
 * Comment represents a comment on a blog post
 */
export type Comment = z.infer<typeof commentSchema>;

export default class BlogApiClient {
  // look at how trim our public methods are!
  async getPosts(): Promise<Result<DomainPost[]>> {
    return this.callApi(`https://example.com/posts`, data =>
      this.handleGetPostsResponse(data)
    );
  }

  // I realized I started expecting a number here instead of a string, whoops!
  async getComments(postId: string): Promise<Result<DomainComment[]>> {
    return this.callApi(`https://example.com/posts/${postId}/comments`, data =>
      this.handleGetCommentsResponse(data)
    );
  }

  // this is where `fetch` happens and where all of our error handling
  // is. notice the generic typing, where the returned Result is generic
  // to the return type of the provided parser callback
  private async callApi<T>(
    url: string,
    responseParser: (json: unknown) => Promise<Result<T>>
  ) {
    let response: Response;
    try {
      response = await fetch(url);
    } catch (err) {
      return this.handleRequestError(err);
    }

    if (response.status >= 500 && response.status < 600) {
      return this.handleServerError(response);
    }

    if (response.status >= 400 && response.status < 500) {
      return this.handleClientError(response);
    }

    try {
      const data = await response.json();
      return await responseParser(data);
    } catch (err) {
      return this.handleJsonParseError(err);
    }
  }

  // I was a little torn on whether I should try to break this down
  // a little further. I ended up having trouble making the Zod handling
  // generic so I decided to be okay with what's here
  private async handleGetPostsResponse(json: unknown) {
    const parseResult = z.array(postSchema).safeParse(json);

    if (!parseResult.success) {
      return this.handleMalformedData(parseResult.error.issues);
    }

    return {
      data: parseResult.data.map(apiPost => new DomainPost(apiPost)),
    } satisfies Ok<DomainPost[]>;
  }

  private async handleGetCommentsResponse(json: unknown) {
    const parseResult = z.array(commentSchema).safeParse(json);

    if (!parseResult.success) {
      return this.handleMalformedData(parseResult.error.issues);
    }

    return {
      data: parseResult.data.map(apiComment => new DomainComment(apiComment)),
    } satisfies Ok<DomainComment[]>;
  }

  // you could choose to reduce duplication here even further by
  // creating a factory method that returns an `Err`, but I wasn't sure
  // if that would make all that much of a difference from a readability
  // standpoint
  private handleRequestError(err: unknown) {
    return {
      error: new Error("Failed to perform a request", {
        cause: err,
      }),
      errorType: "RequestError",
    } satisfies Err;
  }

  private handleClientError(response: Response) {
    return {
      error: new Error("Sent a problematic request to an external resource", {
        cause: response,
      }),
      errorType: "ClientError",
    } satisfies Err;
  }

  private handleMalformedData(issues: z.ZodIssue[]) {
    return {
      error: new Error("Received malformed data", {
        cause: issues,
      }),
      errorType: "TypeError",
    } satisfies Err;
  }

  private handleServerError(response: Response) {
    return {
      error: new Error("Received an error from an external resource", {
        cause: response,
      }),
      errorType: "ServerError",
    } satisfies Err;
  }

  private handleJsonParseError(err: unknown) {
    return {
      error: new Error("Failed to parse valid JSON", { cause: err }),
      errorType: "TypeError",
    } satisfies Err;
  }
}

There you have it! An API client backed up by a wide suite of automated tests. In reality, APIs you integrate with have dozens of endpoints. It’s especially helpful to respect separation of concerns with API clients, because if done thoughtfully it dramatically reduces the amount of code you need to write to add support for a new endpoint.

One last note, there were a few lingering type errors in the spec file for the API client. It can be really tricky to get the typing right when you’re dynamically calling different statically defined methods with their required arguments. I just slapped some @ts-expect-errors when the client methods were invoked, but you may choose to go the extra mile and structure the it.each arguments such that they are generic to the public methods on the API client, with the correct arguments.

Check out the paired repository if you’d like to explore the code in a finished state. I’ll leave a link to the repository below.

As far as a note to end on, what could be better than a suite of passing tests and 100% code coverage:

 ✓ src/domain/Post.spec.ts (1)
 ✓ src/domain/Comment.spec.ts (1)
 ✓ src/BlogApi/BlogApi.spec.ts (20)

 Test Files  3 passed (3)
      Tests  22 passed (22)
   Start at  21:40:34
   Duration  225ms (transform 62ms, setup 0ms, collect 102ms, tests 17ms, environment 0ms, prepare 168ms)

 % Coverage report from v8
-------------------|---------|----------|---------|---------|-------------------
File               | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------|---------|----------|---------|---------|-------------------
All files          |     100 |      100 |     100 |     100 |
 BlogApi           |     100 |      100 |     100 |     100 |
  BlogApiClient.ts |     100 |      100 |     100 |     100 |
 domain            |     100 |      100 |     100 |     100 |
  Comment.ts       |     100 |      100 |     100 |     100 |
  Post.ts          |     100 |      100 |     100 |     100 |
-------------------|---------|----------|---------|---------|-------------------