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:
What happens on one call
- getTodo(params) to Cache lookup (GET only)
- Cache lookup (GET only) to Return cached: hit
- Cache lookup (GET only) to One AbortController: miss
- Timeout to One AbortController: abort('timeout')
- Caller's signal to One AbortController: abort('external')
- One AbortController to fetch attempt
- fetch attempt to Retry with backoff: 5xx / network
- fetch attempt to Store + return: ok
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:
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:
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.
| Failure | Retry? | Why |
|---|---|---|
| 5xx | yes | the server may recover |
| Network (DNS, offline) | yes | usually transient |
| 4xx | no | the same bad request fails the same way |
| Aborted (timeout, caller) | no | someone chose to stop |
| Response isn't JSON | no | it won't be valid JSON the second time either |
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.