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:
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.
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:
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.

