Last active
October 8, 2024 10:53
-
-
Save trvswgnr/109501ac583ac43ea58165f2bee5db8d to your computer and use it in GitHub Desktop.
nice lil typescript fetch wrapper with errors as values
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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