Skip to content

Safe Fetch

Fetch x Zod (Schema Validation)

safeFetch.ts

ts
import { z } from "zod";

class HttpError extends Error {
  public statusCode: number;

  constructor(message: string, statusCode: number) {
    super(message);
    this.name = "HttpError";
    this.statusCode = statusCode;
  }
}

export const safeFetch = async <T extends z.ZodType>(
  url: string,
  schema: T,
  options?: RequestInit
): Promise<z.infer<T>> => {
  const response = await fetch(url, options);

  if (!response.ok) {
    throw new HttpError(response.statusText, response.status);
  }

  try {
    const data = await response.json();
    return schema.parse(data);
  } catch (err) {
    if (err instanceof z.ZodError) {
      throw new Error("Validation Error");
    }
    throw new Error("Parsing Failure");
  }
};

safeFetch.spec.ts

ts
import { vi } from "vitest";
import { z } from "zod";
import { safeFetch } from "./safeFetch";

describe("safeFetch", () => {
  const accessTokenSchema = z.object({
    access_token: z.string(),
    expires_in: z.number()
  });

  type AccessToken = z.infer<typeof accessTokenSchema>;

  beforeEach(() => vi.clearAllMocks());
  afterEach(() => vi.restoreAllMocks());

  it("should return data", async () => {
    const mockData = {
      access_token: "eyJiZWFyZXIiOiJ0b2tlbiJ9",
      expires_in: 300
    };

    globalThis.fetch = vi.fn().mockResolvedValueOnce({
      ok: true,
      json: vi.fn().mockResolvedValueOnce(mockData)
    });

    const data: AccessToken = await safeFetch("http://localhost:3000", accessTokenSchema);
    expect(data).toEqual(mockData);
  });

  it("should throw an (http) error", async () => {
    globalThis.fetch = vi.fn().mockResolvedValueOnce({
      ok: false,
      status: 500,
      statusText: "Internal Server Error"
    });

    await expect(safeFetch("http://localhost:3000", accessTokenSchema)).rejects.toThrow("Internal Server Error");
  });

  it("should pass request options to fetch", async () => {
    const options = {
      method: "POST",
      headers: { "Content-Type": "application/json" }
    };

    globalThis.fetch = vi.fn().mockResolvedValueOnce({
      ok: true,
      json: vi.fn().mockResolvedValueOnce({
        access_token: "eyJiZWFyZXIiOiJ0b2tlbiJ9",
        expires_in: 300
      })
    });

    await safeFetch("http://localhost:3000", accessTokenSchema, options);
    expect(globalThis.fetch).toHaveBeenCalledWith("http://localhost:3000", options);
  });

  it("should throw an (validation) error", async () => {
    globalThis.fetch = vi.fn().mockResolvedValueOnce({
      ok: true,
      json: vi.fn().mockResolvedValueOnce({
        bearer_token: "eyJiZWFyZXIiOiJ0b2tlbiJ9",
        expires_in: "300"
      })
    });

    await expect(safeFetch("http://localhost:3000", accessTokenSchema)).rejects.toThrow("Validation Error");
  });
});

Released under the MIT License.