Skip to content

Instantly share code, notes, and snippets.

@trvswgnr
Last active October 8, 2024 10:53
Show Gist options
  • Save trvswgnr/109501ac583ac43ea58165f2bee5db8d to your computer and use it in GitHub Desktop.
Save trvswgnr/109501ac583ac43ea58165f2bee5db8d to your computer and use it in GitHub Desktop.
nice lil typescript fetch wrapper with errors as values
import { describe, it, expect, spyOn } from "bun:test";
import { fetchJson } from "./fetchJson";
class MockResponse {
static instanceCount = 0;
constructor(
public readonly ok: boolean,
private jsonSuccess: boolean | "bad parse",
) {
MockResponse.instanceCount++;
}
json() {
if (this.jsonSuccess === "bad parse") {
return JSON.parse("[");
}
if (this.jsonSuccess) {
return Promise.resolve({ foo: "bar" });
}
return Promise.reject(new Error("json error"));
}
}
describe("fetchJson", () => {
it("should return successful response when fetch is successful and OK, with no predicate", async () => {
setupMocks({
fetchResult: { success: true, ok: true },
jsonResult: { success: true, data: { foo: "bar" } },
});
const result = await fetchJson("test", returnTrue);
expect(result).toEqual({ foo: "bar" });
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.Response.prototype.json).toHaveBeenCalledTimes(1);
expect(MockResponse.instanceCount).toEqual(1);
});
it("should return successful response when fetch is successful and OK, with valid predicate", async () => {
setupMocks({
fetchResult: { success: true, ok: true },
jsonResult: { success: true, data: { foo: "bar" } },
});
const predicate = (x: unknown): x is { foo: "bar" } => {
return (
typeof x === "object" &&
x !== null &&
"foo" in x &&
x.foo === "bar"
);
};
const result = await fetchJson("test", predicate);
expect(result).toEqual({ foo: "bar" });
});
it("should return Error 'fetch error' when fetch is unsuccessful", async () => {
setupMocks({
fetchResult: { success: false, ok: false },
jsonResult: { success: true, data: { foo: "bar" } },
});
const result = await fetchJson("test", returnTrue);
expect(result).toBeInstanceOf(Error);
expect(result).toEqual(new Error("fetch error"));
});
it("should return Error 'expected ok response' when fetch is successful but not OK", async () => {
setupMocks({
fetchResult: { success: true, ok: false },
jsonResult: { success: true, data: { foo: "bar" } },
});
const result = await fetchJson("test", returnTrue);
expect(result).toBeInstanceOf(Error);
expect(result).toEqual(new Error("expected ok response"));
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.Response.prototype.json).toHaveBeenCalledTimes(0);
expect(MockResponse.instanceCount).toEqual(1);
});
it("should return Error 'json error' when fetch is successful and ok but json is unsuccessful", async () => {
setupMocks({
fetchResult: { success: true, ok: true },
jsonResult: { success: false, data: { foo: "bar" } },
});
const result = await fetchJson("test", returnTrue);
expect(result).toBeInstanceOf(Error);
expect(result).toEqual(new Error("json error"));
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.Response.prototype.json).toHaveBeenCalledTimes(1);
expect(MockResponse.instanceCount).toEqual(1);
});
it("should return Error 'invalid data' when fetch is successful and ok, json is ok, but validation fails", async () => {
setupMocks({
fetchResult: { success: true, ok: true },
jsonResult: { success: true, data: { foo: "bar" } },
});
const predicate = (x: unknown): x is string => x === "lol no";
const result = await fetchJson("test", predicate);
expect(result).toBeInstanceOf(Error);
expect(result).toEqual(new Error("invalid data"));
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.Response.prototype.json).toHaveBeenCalledTimes(1);
expect(MockResponse.instanceCount).toEqual(1);
});
});
function returnTrue<T>(x: unknown): x is T {
return true;
}
function setupMocks<T>({
fetchResult,
jsonResult,
}: {
fetchResult: { success: boolean; ok: boolean };
jsonResult: { success: boolean; data: T };
}) {
clearMocks();
MockResponse.instanceCount = 0;
const response = Object.setPrototypeOf(
new MockResponse(fetchResult.ok, jsonResult.success),
new Response(),
);
spyOn(globalThis.Response.prototype, "json").mockImplementation(() => {
if (jsonResult.success) {
return Promise.resolve(jsonResult.data);
}
return Promise.reject(new Error("json error"));
});
spyOn(globalThis, "fetch").mockImplementation(() => {
if (fetchResult.success) {
return Promise.resolve(response);
}
return Promise.reject(new Error("fetch error"));
});
}
function clearMocks() {
spyOn(globalThis, "fetch").mockClear();
spyOn(globalThis.Response.prototype, "json").mockClear();
}
export async function fetchJson<T>(
url: string | Request | URL,
predicate: (x: unknown) => x is Ok<T>,
init?: FetchRequestInit,
): Promise<Result<T, Error>> {
const response = await fetch(url, init)
.then(parseWith(isOkResponse, "expected ok response"))
.catch(intoError);
if (response instanceof Error) return response;
return await response.json()
.then(parseWith(predicate, "invalid data"))
.catch(intoError);
}
export function parseWith<T>(
predicate: (x: unknown) => x is T,
message?: string,
): (x: unknown) => T {
return (x) => {
if (!predicate(x)) throw new Error(message ?? `failed to parse value`);
return x;
};
}
export function isOkResponse(r: unknown): r is Response & { ok: true } {
return r instanceof Response && r.ok;
}
export function intoError(e: unknown): Err<Error> {
if (e instanceof Error) return e;
if (typeof e === "object" && e !== null) {
const x = resultOf(JSON.stringify, e);
if (x instanceof Error) return x;
return new Error(x);
}
return new Error(String(e));
}
export function resultOf<A extends readonly any[], R>(
fn: (...args: A) => Ok<R>,
...args: A
): Result<R, Error> {
try {
return fn(...args);
} catch (e) {
return intoError(e);
}
}
export type Result<T, E extends Error = Error> = Ok<T> | Err<E>;
export type Ok<T> = T extends Error
? never
: (0 extends 1 & T ? true : false) extends true
? never
: T;
export type Err<E extends Error> = E & Error;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment