Yieldless
Reference

yieldless/fetch

Native fetch helpers with tuple errors, timeouts, status checks, and JSON parsing.

yieldless/fetch keeps HTTP calls on the platform fetch() API while handling the production chores that otherwise show up in every service: tuple errors, deadlines, non-2xx responses, JSON parsing, and abort forwarding.

Exports

  • fetchSafe(input, options): Promise<SafeResult<Response, FetchSafeError>>
  • fetchJsonSafe<T>(input, options): Promise<SafeResult<T, FetchJsonError>>
  • readJsonSafe<T>(response): Promise<SafeResult<T, JsonParseError>>
  • class HttpStatusError extends Error
  • class JsonParseError extends Error
  • class FetchUnavailableError extends Error
  • type FetchSafeOptions = RequestInit & { timeoutMs?, isOkStatus?, fetch? }

Example

import { fetchJsonSafe } from "yieldless/fetch";

const [error, user] = await fetchJsonSafe<{ id: string; name: string }>(
  `https://api.example.com/users/${userId}`,
  {
    headers: {
      accept: "application/json",
    },
    timeoutMs: 5_000,
    signal,
  },
);

if (error) {
  return [error, null] as const;
}

return [null, user] as const;

Fetching one record by ID

Build the URL in normal TypeScript, then pass the active AbortSignal into fetchJsonSafe().

import { fetchJsonSafe } from "yieldless/fetch";

async function fetchUserById(
  apiBaseUrl: string,
  userId: number,
  signal: AbortSignal,
) {
  const url = new URL(`/users/${String(userId)}`, apiBaseUrl);

  return await fetchJsonSafe<User>(url, {
    headers: { accept: "application/json" },
    timeoutMs: 5_000,
    signal,
  });
}

This shape works well inside forEach(), mapAsyncLimit(), safeRetry(), and cache loaders because all of them pass a signal to the work they run.

Status handling

fetchSafe() treats response.ok as success by default. Override isOkStatus when an API uses a status like 304 or 409 as part of a normal workflow.

const [error, response] = await fetchSafe(url, {
  isOkStatus: (response) => response.ok || response.status === 304,
});

Behavior notes

  • Request options are ordinary RequestInit options.
  • timeoutMs derives an AbortSignal and cleans up the timer when the request settles.
  • Non-ok responses return HttpStatusError with the original response attached.
  • fetchJsonSafe() only parses JSON after fetchSafe() succeeds.
  • readJsonSafe() wraps parser failures in JsonParseError instead of throwing.

Good

Keep HTTP concerns at the HTTP boundary.

const [error, user] = await fetchJsonSafe<User>(url, {
  headers: { accept: "application/json" },
  timeoutMs: 5_000,
  signal,
});

if (error instanceof HttpStatusError && error.status === 404) {
  return [new NotFoundError("User not found"), null] as const;
}

Inject a custom fetch in tests or runtimes that do not expose global fetch.

await fetchJsonSafe(url, {
  fetch: testFetch,
});

Avoid

Do not parse JSON before checking status.

const response = await fetch(url);
const body = await response.json();

if (!response.ok) {
  return [new Error(body.message), null] as const;
}

Use fetchSafe() when you need the raw response and fetchJsonSafe() when success means "valid JSON body".

On this page