One factory, called once per endpoint, gives you a typed request/response pair plus opt-in caching, opt-in retries, and cancellation. Full breakdown: Building a Typed Fetch Factory.
@itsnas — use-todos.ts
code›use-todos.ts
export function useTodos() {
const [state, dispatch] = useReducer(reducer<Todo[]>, {
status: 'idle',
})
useEffect(() => {
const controller = new AbortController()
async function fetchTodos() {
try {
dispatch({ type: 'FETCH_START' })
const res = await api.getTodos(undefined, {
signal: controller.signal,
})
dispatch({ type: 'FETCH_SUCCESS', payload: res })
} catch (err) {
if (err instanceof FetchAbortError) return
dispatch({
type: 'FETCH_ERROR',
payload: describeFetchError(err),
})
}
}
fetchTodos()
return () => controller.abort()
}, [])
return state
}
const BASE_URL = 'https://jsonplaceholder.typicode.com'
// @src/code/fetch-wrapper
const api = {
getTodos: createFetcher('/todos', 'get', {
baseUrl: BASE_URL,
cacheTtlMs: 30_000,
retries: 2,
}),
}
function describeFetchError(err: unknown): string {
if (err instanceof FetchError) {
return `API error ${err.status}: ${err.statusText}`
}
if (err instanceof FetchParseError) {
return 'Received an invalid response from the server'
}
return 'Something went wrong'
}main
Nas (@itsnas)
export function createFetcher<
P extends keyof schema,
M extends keyof schema[P],
>(path: P, method: M, fetcherOptions: FetcherOptions = {}) {
const {
baseUrl = 'https://api.example.com',
timeoutMs = 10_000,
cacheTtlMs = 0,
cache = defaultFetchCache,
retries = 0,
retryDelayMs = 300,
} = fetcherOptions
const httpMethod = (method as string).toUpperCase()
const isCacheable = httpMethod === 'GET' && cacheTtlMs > 0
return async (
params?: Params<P, M>,
callOptions: CallOptions = {},
): Promise<ResponseT<P, M>> => {
const fetchUrl = buildUrl(path as string, params, baseUrl)
const cacheKey = buildCacheKey(fetchUrl, httpMethod)
if (isCacheable && !callOptions.skipCache) {
const cached = cache.get<ResponseT<P, M>>(cacheKey)
if (cached !== undefined) return cached
}
const controller = new AbortController()
const timeoutId =
timeoutMs > 0
? setTimeout(() => controller.abort('timeout'), timeoutMs)
: undefined
const onExternalAbort = () => controller.abort('external')
callOptions.signal?.addEventListener('abort', onExternalAbort)
const options = buildRequestInit(
httpMethod,
params?.requestBody,
controller.signal,
fetcherOptions.headers,
callOptions.headers,
)
try {
const data = await runWithRetries<ResponseT<P, M>>(
() => attemptRequest(fetchUrl, options, controller),
retries,
retryDelayMs,
)
if (isCacheable && data !== undefined)
cache.set(cacheKey, data, cacheTtlMs)
return data
} finally {
if (timeoutId) clearTimeout(timeoutId)
callOptions.signal?.removeEventListener('abort', onExternalAbort)
}
}
}
async function attemptRequest<T>(
fetchUrl: URL,
options: RequestInit,
controller: AbortController,
): Promise<T> {
try {
const res = await fetch(fetchUrl, options)
if (!res.ok) throw await toFetchError(fetchUrl, res)
if (res.status === 204) return undefined as T
try {
return (await res.json()) as T
} catch (err) {
throw new FetchParseError(fetchUrl.toString(), err)
}
} catch (err) {
if (err instanceof FetchError || err instanceof FetchParseError) throw err
if (controller.signal.aborted) {
throw new FetchAbortError(
controller.signal.reason === 'timeout' ? 'timeout' : 'external',
)
}
throw err // network-level failure (DNS, connection refused, offline, etc.)
}
}
async function toFetchError(fetchUrl: URL, res: Response): Promise<FetchError> {
let body: unknown
try {
body = await res.clone().json()
} catch {
// response wasn't JSON; leave body undefined
}
return new FetchError(
`Request to ${fetchUrl} failed with status ${res.status}`,
{
status: res.status,
statusText: res.statusText,
url: fetchUrl.toString(),
body,
},
)
}main
Nas (@itsnas)
export type Params<
P extends keyof schema,
M extends keyof schema[P],
> = (IsAbsent<ParamsInner<P, M>['query']> extends true
? { query?: never }
: { query?: ParamsInner<P, M>['query'] }) &
(IsAbsent<ParamsInner<P, M>['path']> extends true
? { path?: never }
: { path: ParamsInner<P, M>['path'] }) &
(IsAbsent<ParamsInner<P, M>['requestBody']> extends true
? { requestBody?: never }
: { requestBody: ParamsInner<P, M>['requestBody'] })
type ParamsInner<
P extends keyof schema,
M extends keyof schema[P],
> = schema[P][M] extends {
parameters: {
query?: infer Q
path?: infer PP
}
requestBody?: { content: infer RB }
}
? {
query: Q
path: PP
requestBody: RB
}
: never
type IsAbsent<T> = [T] extends [never]
? true
: unknown extends T
? true
: [T] extends [undefined]
? true
: false
export type ResponseT<
P extends keyof schema,
M extends keyof schema[P],
> = schema[P][M] extends { response: { content: infer R } } ? R : never
export interface FetcherOptions {
baseUrl?: string
timeoutMs?: number
cacheTtlMs?: number
cache?: Pick<FetchCache, 'get' | 'set' | 'delete'>
retries?: number
retryDelayMs?: number
headers?: HeadersInit
}
export interface CallOptions {
signal?: AbortSignal
skipCache?: boolean
headers?: HeadersInit
}main
Nas (@itsnas)
export class FetchError<TBody = unknown> extends Error {
readonly status: number
readonly statusText: string
readonly url: string
readonly body: TBody | undefined
constructor(
message: string,
opts: { status: number; statusText: string; url: string; body?: TBody },
) {
super(message)
this.name = 'FetchError'
this.status = opts.status
this.statusText = opts.statusText
this.url = opts.url
this.body = opts.body
}
}
export class FetchAbortError extends Error {
readonly reason: 'timeout' | 'external'
constructor(reason: 'timeout' | 'external') {
super(reason === 'timeout' ? 'Request timed out' : 'Request was aborted')
this.name = 'FetchAbortError'
this.reason = reason
}
}
export class FetchParseError extends Error {
readonly url: string
override readonly cause: unknown
constructor(url: string, cause: unknown) {
super(`Failed to parse response body as JSON from ${url}`)
this.name = 'FetchParseError'
this.url = url
this.cause = cause
}
}
interface CacheEntry {
value: unknown
expiresAt: number
}
export class FetchCache {
private store = new Map<string, CacheEntry>()
get<T>(key: string): T | undefined {
const entry = this.store.get(key)
if (!entry) return undefined
if (Date.now() > entry.expiresAt) {
this.store.delete(key)
return undefined
}
return entry.value as T
}
set(key: string, value: unknown, ttlMs: number): void {
this.store.set(key, { value, expiresAt: Date.now() + ttlMs })
}
delete(key: string): void {
this.store.delete(key)
}
clear(): void {
this.store.clear()
}
}
export const defaultFetchCache = new FetchCache()
export function buildCacheKey(url: URL, method: string): string {
return `${method.toUpperCase()} ${url.toString()}`
}
export function buildUrl(
path: string,
params: { path?: unknown; query?: unknown } | undefined,
baseUrl: string,
): URL {
const templateParams = path.match(/{([^}]+)}/g)
let realPath = path
if (params?.path && templateParams) {
const pathParams = params.path as Record<string, unknown>
for (const templateParam of templateParams) {
const paramName = templateParam.slice(1, -1)
realPath = realPath.replace(templateParam, String(pathParams[paramName]))
}
}
const url = new URL(realPath, baseUrl)
if (params?.query) {
for (const [key, value] of Object.entries(
params.query as Record<string, unknown>,
)) {
if (value !== undefined) url.searchParams.append(key, String(value))
}
}
return url
}
export function buildRequestInit(
method: string,
requestBody: unknown,
signal: AbortSignal,
defaultHeaders: HeadersInit | undefined,
callHeaders: HeadersInit | undefined,
): RequestInit {
const hasBody = requestBody !== undefined
const headers = {
...headersToRecord(defaultHeaders),
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...headersToRecord(callHeaders),
}
return {
method,
signal,
...(hasBody ? { body: JSON.stringify(requestBody) } : {}),
...(Object.keys(headers).length > 0 ? { headers } : {}),
}
}
function headersToRecord(headers?: HeadersInit): Record<string, string> {
if (!headers) return {}
if (headers instanceof Headers) {
const record: Record<string, string> = {}
headers.forEach((value, key) => (record[key] = value))
return record
}
if (Array.isArray(headers)) return Object.fromEntries(headers)
return { ...headers }
}
export async function runWithRetries<T>(
attempt: () => Promise<T>,
retries: number,
retryDelayMs: number,
): Promise<T> {
let attemptNum = 0
while (true) {
try {
return await attempt()
} catch (err) {
if (attemptNum >= retries || !isRetryableError(err)) throw err
attemptNum++
await backoff(attemptNum, retryDelayMs)
}
}
}
export function isRetryableError(err: unknown): boolean {
if (err instanceof FetchError) return isRetryableStatus(err.status)
if (err instanceof FetchAbortError || err instanceof FetchParseError)
return false
return true // unclassified network-level failure
}
export function isRetryableStatus(status: number): boolean {
return status >= 500 && status < 600
}
function backoff(attempt: number, baseDelayMs: number): Promise<void> {
return sleep(baseDelayMs * 2 ** (attempt - 1))
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}main
Nas (@itsnas)