Building a Typed Fetch Factory

Naseebullah Ahmadi  Senior Software Engineer, London

How a single createFetcher factory infers request/response types from an OpenAPI schema and layers in caching, retries, and cancellation, and why each piece is built the way it is.

8 min read
#engineering #frontend

Every project ends up with its own hand-rolled fetch wrapper eventually. It's the one that knows the base URL, retries flaky requests, maybe caches a few GET calls, maybe not. They're usually written under deadline pressure, so they're rarely typed properly and rarely consistent from one project to the next.

createFetcher wraps that baseline into a single factory. Given a path and a method, it returns a function whose parameters and return type are both inferred from an OpenAPI schema. Caching, retries, and cancellation are opt-in config, and none of it is re-implemented per endpoint.

The short, copy-pasteable version of everything below lives in the Typed Fetch Factory Quick Snip. This post is the walkthrough.

Inferring the request shape from the schema

An OpenAPI codegen tool (I use openapi-typescript) turns a spec into a schema type: a giant object type keyed by path, then by method. ParamsInner picks one path+method pair out of it and pulls the query, path, and request-body shapes out with infer. Params<P, M> and ResponseT<P, M> then turn that into the exact request/response shape for that one endpoint (full types in the snip's types.ts).

The one real addition over the original: a field the schema never declared can infer as unknown, undefined, or never, depending on how it's absent. The original post's check only covered the unknown case. IsAbsent treats all three the same, so an endpoint with no path parameters doesn't end up with a wrongly-required path: undefined:

@itsnas TypeScript
type IsAbsent<T> = [T] extends [never]
  ? true
  : unknown extends T
    ? true
    : [T] extends [undefined]
      ? true
      : false
main
Nas (@itsnas)

The result: calling getTodo({ path: { todoId: 1 } }) type-checks, and calling it without path doesn't. The awaited response comes back as the real Todo type, and none of it is hand-written per endpoint.

Where the runtime behavior lives

Everything above is compile-time only: it disappears once #typescript finishes checking your code. createFetcher is where the actual request happens, built around one idea: a single AbortController owns the request's lifetime. Everything else (cache, retries, timeout) wraps around that one fetch call.

@itsnas TypeScript
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)
    }
  }
}
main
Nas (@itsnas)

Two details worth calling out. First, the cache check happens before the AbortController is even created: a cache hit never touches fetch at all, so there's nothing to time out or abort.

Second, the timeout and an external signal (e.g. from a component's useEffect cleanup) both abort the same controller, for two different reasons ('timeout' vs 'external'). That's what lets a single try/catch downstream tell the two apart via FetchAbortError's reason.

Deciding what's worth retrying

Not every failure deserves a retry. A 4xx means the request itself was malformed or unauthorized, so retrying sends the same bad request again. A 5xx or a network-level failure (DNS, connection refused, offline) is usually transient, so it's worth one more attempt with backoff:

@itsnas TypeScript
function isRetryableError(err: unknown): boolean {
  if (err instanceof FetchError) return err.status >= 500
  if (err instanceof FetchAbortError || err instanceof FetchParseError) {
    return false
  }
  return true // unclassified network-level failure
}
main
Nas (@itsnas)

An aborted request (timeout or external) and a parse failure are both explicitly excluded. Retrying an intentional cancellation makes no sense, and a response that isn't valid JSON won't become valid JSON on a second try. The actual retry loop (exponential backoff, attempt counting) is mechanical from there; see utils.ts in the snip.

The cache is deliberately dumb

No LRU eviction, no cross-tab sync, no revalidation strategy, just a Map with a per-entry expiry, scoped to one page's lifetime (full class in the snip). That's intentional: get/set is the entire surface createFetcher needs. Swapping in localStorage, IndexedDB, or a shared Redis-backed cache is a matter of matching that same shape, not rewriting the fetcher.

Where it all lands

cacheTtlMs, retries, and timeoutMs are per-fetcher config; skipCache and an external signal are per-call overrides. Nothing here needs re-solving per endpoint: you call createFetcher once. Every call through it is typed, optionally cached, optionally retried, and always cancellable. See the snip for the copy-paste version, or the Todo demo for it running for real.

End of entry · Keep exploring

What's next in the notebook?

Keep reading — more from where that came from.

Featured next
12 min read
0%

Migrating Schema-Per-Tenant Databases at Scale

Choosing physical tenant isolation over a shared, RLS-scoped schema buys two new problems: knowing where a tenant's data actually lives, and running one migration correctly hundreds of times instead of once. Neither has an app-code fix, both need their own infrastructure.

21 min read
#engineering

Designing Multi-Tenant APIs That Scale

A missing tenant filter is a data leak, not a crash. Row-level security fixes that structurally, but rate limits, connection pools, and error codes built for one instance break the same quiet way once the API runs as several.

0%
17 min read
#engineering

Why Payment Retries Need Idempotency

A plain payment endpoint looks correct until you trace what a double-click, a timed-out request, or a redelivered webhook actually does to it. Each one turns one payment into two. Idempotency keys are the fix, at two layers most write-ups skip.

0%
7 min read
#algorithms

Two Pointers

Two indices walking through one ordered structure, discarding the side that cannot improve the answer at every step and replacing a nested loop with a single pass.

0%
4 min read
#engineering

Disecting my portfolio

It took me 3 years to build my portfolio. The process was bitter yet rewarding. Learnt many things and adopted good habbits.

0%