Skip to content

Building a Typed Fetch Factory

Naseebullah AhmadiSenior Software Engineer, London

Every project grows a hand-rolled fetch wrapper: untyped, half-cached, retried by accident. Here's one built on purpose, with types inferred from an OpenAPI schema and every layer explained.

7 min read
#engineering #frontend

createFetcher(path, method) returns one function per endpoint. Its parameters and return type come straight from the API's schema, and caching, retries, timeouts and cancellation are config, not code you rewrite per call. You call it once:

@itsnas TypeScript
const getTodo = createFetcher('/todos/{todoId}', 'get', {
  cacheTtlMs: 30_000,
  retries: 2,
})
 
const todo = await getTodo({ path: { todoId: 1 } }) // typed as Todo
main
Nas (@itsnas)

What happens on one call

  1. getTodo(params) to Cache lookup (GET only)
  2. Cache lookup (GET only) to Return cached: hit
  3. Cache lookup (GET only) to One AbortController: miss
  4. Timeout to One AbortController: abort('timeout')
  5. Caller's signal to One AbortController: abort('external')
  6. One AbortController to fetch attempt
  7. fetch attempt to Retry with backoff: 5xx / network
  8. fetch attempt to Store + return: ok
A cache hit returns before anything else exists. A miss gets one AbortController that both the timeout and the caller can trip, and retries run inside it.

That diagram is the whole runtime. The rest of the post is how each box is built.

Types from the schema

#typescript does the first half at compile time. A codegen tool (I use openapi-typescript) turns the spec into a schema type keyed by path, then method. Params<P, M> pulls the query, path and body shapes for one endpoint out with infer, and ResponseT<P, M> pulls out its response (full types in the snip's types.ts).

The one addition over the original: a field the schema never declared can infer as unknown, undefined or never, depending on how it's absent. IsAbsent treats all three as "not there", so an endpoint with no path parameters doesn't demand a pointless path: undefined:

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

So getTodo({ path: { todoId: 1 } }) type-checks, leaving path out doesn't, and nothing is hand-written per endpoint.

The factory

Here's the runtime half, the diagram as code:

@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 things to notice. The cache check runs before the controller exists, so a hit never touches fetch and has nothing to time out. And the timeout and the caller's signal abort the same controller with different reasons, so one catch downstream can tell "too slow" from "the component unmounted" through FetchAbortError's reason.

What's worth retrying

A retry only helps if the next attempt could get a different answer.

FailureRetry?Why
5xxyesthe server may recover
Network (DNS, offline)yesusually transient
4xxnothe same bad request fails the same way
Aborted (timeout, caller)nosomeone chose to stop
Response isn't JSONnoit won't be valid JSON the second time either
@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)

The loop around it (exponential backoff, attempt counting) is mechanical; it's in the snip's utils.ts.

The cache is deliberately dumb

A Map with a per-entry expiry, scoped to the page. No LRU, no cross-tab sync, no revalidation. get and set are the entire surface the factory uses, so swapping in localStorage, IndexedDB or a Redis-backed cache means matching two methods, not touching the fetcher.

That's the shape worth taking away even if you never use this code: types derived from the source of truth, one owner for a request's lifetime, and retries decided by whether trying again could change the answer.