Extending Awaitly
Build your own utilities that integrate seamlessly with awaitly workflows. This guide walks through the pattern using the built-in fetch helper as a real example.
Why Build Custom Utilities?
Section titled “Why Build Custom Utilities?”When you use the same patterns repeatedly, wrapping them in a utility helps:
- Enforce Result types - Never forget to handle errors
- Reduce boilerplate - Write the error handling once
- Type safety - Get typed errors that TypeScript understands
For example, instead of writing this every time:
const res = await fetch('/api/users');if (!res.ok) throw new Error(`HTTP ${res.status}`);return res.json();You can use a utility that returns a Result:
const result = await step('fetchJson', () => fetchJson('/api/users'));// TypeScript knows: result.ok ? result.value : result.errorThe Core Pattern
Section titled “The Core Pattern”Every awaitly utility follows the same pattern:
import type { AsyncResult } from 'awaitly';import { ok, err } from 'awaitly';
function myUtility<T>(input: string): AsyncResult<T, 'ERROR_A' | 'ERROR_B'> { // Success: return ok(value) // Failure: return err(errorType, { cause: details })}The key rules:
- Return
AsyncResult<T, E>- A Promise that resolves toOk<T>orErr<E> - Never throw - Always return
err()instead of throwing - Include error context - Put details in the
causefield for debugging
Real Example: The Fetch Helper
Section titled “Real Example: The Fetch Helper”Let’s walk through how fetchJson from awaitly/fetch is built. This is actual code from the library.
Step 1: Define Your Error Types
Section titled “Step 1: Define Your Error Types”First, define the errors your utility can return. Use string literals (like "NOT_FOUND") instead of classes or enums - they’re simpler and TypeScript handles them better.
// Default errors that cover common HTTP scenariostype DefaultFetchError = | "NOT_FOUND" | "BAD_REQUEST" | "UNAUTHORIZED" | "FORBIDDEN" | "SERVER_ERROR" | "NETWORK_ERROR";Why string literals?
- TypeScript can narrow them in
ifstatements - They’re easy to read in error messages
- No need for imports or class definitions
- They work with discriminated unions (more on this later)
Step 2: Define Options
Section titled “Step 2: Define Options”Extend existing types when possible. For fetch, we extend RequestInit (the standard fetch options) and add our custom error handling:
// A function that maps status codes to errorstype FetchErrorMapper<TError> = ( status: number, response: Response) => TError;
// Extend RequestInit, add custom error optiontype FetchOptions<TError = DefaultFetchError> = RequestInit & { error?: FetchErrorMapper<TError> | TError;};This lets users pass standard fetch options (method, headers, body) plus an optional error mapper.
Step 3: Implement the Core Logic
Section titled “Step 3: Implement the Core Logic”Here’s the core pattern - handle both success and error paths, never throw:
import type { AsyncResult, Result } from 'awaitly';import { ok, err } from 'awaitly';
async function fetchWithErrorHandling<T, TError>( url: string | URL | Request, options: FetchOptions<TError> | undefined, parseResponse: (response: Response) => Promise<T>): Promise<Result<T, TError>> { try { const { error: errorOption, ...fetchOptions } = options ?? {}; const response = await fetch(url, fetchOptions);
// Success path (2xx status) if (response.ok) { try { const data = await parseResponse(response); return ok(data); } catch (parseError) { // JSON parsing failed return err("NETWORK_ERROR" as TError, { cause: parseError }); } }
// HTTP error path (4xx, 5xx) const status = response.status; let errorValue: TError;
if (errorOption !== undefined) { if (typeof errorOption === "function") { // Custom mapper: (status, response) => error errorValue = (errorOption as FetchErrorMapper<TError>)(status, response); } else { // Single value for all errors errorValue = errorOption; } } else { // Use default mapping errorValue = defaultErrorMapper(status) as TError; }
// Include status in cause for debugging return err(errorValue, { cause: { status, statusText: response.statusText } });
} catch (fetchError) { // Network error (no connection, CORS, timeout) return err("NETWORK_ERROR" as TError, { cause: fetchError }); }}Key points:
- Successful responses (
response.ok) returnok(data) - HTTP errors (404, 500, etc.) return
err(errorType)with status incause - Network errors (fetch throws) return
err("NETWORK_ERROR")with the original error incause - The
causefield preserves debugging info without cluttering the error type
Step 4: Provide Sensible Defaults
Section titled “Step 4: Provide Sensible Defaults”Create a default mapper for common cases:
function defaultErrorMapper(status: number): DefaultFetchError { if (status === 404) return "NOT_FOUND"; if (status === 400) return "BAD_REQUEST"; if (status === 401) return "UNAUTHORIZED"; if (status === 403) return "FORBIDDEN"; if (status >= 500) return "SERVER_ERROR"; return "SERVER_ERROR"; // Fallback}This handles 80% of use cases. Users who need custom errors can override with the error option.
Step 5: Create Variants
Section titled “Step 5: Create Variants”If you have multiple similar functions (like fetchJson, fetchText, fetchBlob), extract the shared logic into a helper and pass the variant-specific behavior as a parameter:
// Each variant just specifies how to parse the responseexport function fetchJson<T, TError = DefaultFetchError>( url: string | URL | Request, options?: FetchOptions<TError>): AsyncResult<T, TError> { return fetchWithErrorHandling( url, options, async (response) => { const text = await response.text(); return text ? JSON.parse(text) : null; } );}
export function fetchText<TError = DefaultFetchError>( url: string | URL | Request, options?: FetchOptions<TError>): AsyncResult<string, TError> { return fetchWithErrorHandling( url, options, async (response) => response.text() );}This DRY approach means bug fixes and improvements happen in one place.
Behavior of the awaitly/fetch module
Section titled “Behavior of the awaitly/fetch module”The real awaitly/fetch module (fetchJson, fetchText, fetchBlob, fetchArrayBuffer) follows the same pattern but uses typed errors and matches standard fetch semantics:
Typing
- You can pass your own generic for the success type:
fetchJson<MyType>(url)so the result isAsyncResult<MyType | null, ...>. Use the optionaldecodeoption to validate at runtime (e.g. with Zod) and get a typedT.
Signal and Request
- Options extend
RequestInit, so you can pass standard fetch options (method,headers,body,cache, etc.) as well as awaitly’ssignalandtimeoutMs. - When the URL is a
Requestthat already has asignal: only explicitsignal: nullclears it (disconnects from the request’s abort). Passingsignal: undefinedor omittingsignalpreserves the request’s signal. This matches standard fetch behavior. timeoutMscomposes with the chosen signal (internal timeout that aborts the request).
AsyncResult contract (no throws)
- All failures are returned as
err(...), never thrown:- fetchJson: network/abort/timeout →
FetchNetworkError/FetchAbortError/FetchTimeoutError; HTTP non-2xx →FetchHttpError; body read failure or invalid JSON →FetchParseError; decode failure →FetchDecodeError. - fetchText, fetchBlob, fetchArrayBuffer: body read failures (e.g. stream error) →
FetchNetworkError; other failures as above.
- fetchJson: network/abort/timeout →
So reading the success body (e.g. response.text()) never escapes as a thrown promise; it is always turned into a typed error result.
Resilience
- You can pass
retryin options to get retries without using a step: same backoff andretryOnsemantics asstep.retry. Use a number for attempts only (retry: 3) or a fullRetryOptionsobject (retry: { attempts: 3, retryOn: (err) => ... }). Default: no retry. Soimport { fetchJson } from 'awaitly/fetch'gives you a super-powered fetch (no throw, typed errors, timeout, signal, optional retry) in one place. - If you use workflows, you can still combine with
step.retrywhen you want step-level retry; the fetchretryoption is for when you’re not in a step.
Step 6: Add to Build (Library Authors)
Section titled “Step 6: Add to Build (Library Authors)”If you’re contributing to awaitly or building a plugin, add your entry point to the build:
tsup.config.ts:
entry: { // ... other entries fetch: 'src/fetch.ts',}package.json:
{ "exports": { "./fetch": { "types": "./dist/fetch.d.ts", "import": "./dist/fetch.js", "require": "./dist/fetch.cjs" } }}Testing Your Utility
Section titled “Testing Your Utility”Test both success and error paths. Here’s a quick example:
import { describe, it, expect, vi } from 'vitest';import { fetchJson } from './fetch';
describe('fetchJson', () => { it('returns ok with parsed JSON on success', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve('{"name":"Alice"}'), });
const result = await fetchJson('/api/user');
expect(result.ok).toBe(true); if (result.ok) { expect(result.value).toEqual({ name: 'Alice' }); } });
it('returns NOT_FOUND error on 404', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found', });
const result = await fetchJson('/api/user');
expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toBe('NOT_FOUND'); expect(result.cause).toEqual({ status: 404, statusText: 'Not Found', }); } });
it('returns NETWORK_ERROR when fetch throws', async () => { const networkError = new Error('Failed to fetch'); global.fetch = vi.fn().mockRejectedValue(networkError);
const result = await fetchJson('/api/user');
expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toBe('NETWORK_ERROR'); expect(result.cause).toBe(networkError); } });});For more testing patterns, see the [Testing guide/testing/).
Checklist
Section titled “Checklist”When building a custom utility:
- Return
AsyncResult<T, E>from your function - Define clear error types as string literals
- Use
ok(value)for success,err(type, { cause })for failure - Never throw - always return
err() - Include debugging info in the
causefield - Provide sensible defaults for common cases
- Allow customization via options
- Test success path and each error type
Next Steps
Section titled “Next Steps”- [Result Types/foundations/result-types/) - Deep dive into
Ok,Err, and type narrowing - [Testing/guides/testing/) - Comprehensive testing patterns for workflows
- [Retries & Timeouts/guides/retries-timeouts/) - Add resilience to your utilities