Design notes

What an API layer is actually for

Most API layers are judged on what they can do. The more useful question is what they cost you on the path you walk a hundred times a day. Here is how @tesyl/hapi answers that — and the closures doing the work underneath.

@tesyl/hapi v0.5.0·12 min read·Ergonomics · Readability · Closures

Every frontend eventually grows a folder called api/. It starts as a thin wrapper over fetch. Then someone adds retries. Then someone adds a Zod parse, but only on the endpoints they were working on that week. Then someone adds a React Query hook per endpoint, and the hooks drift from the promises, and now there are two ways to call the same thing that disagree about what comes back.

The folder is not badly written. It is just the accumulated residue of everyone solving their own problem at their own call site. Nothing forces the pieces to agree.

An API layer earns its place when calling an endpoint the obvious way is also calling it the correct way.

1. What it actually does

Functionality, stated plainly.

You declare an endpoint once — method, path, and the schemas for its request, response, and path parameters. In return you get one object carrying every way you might call it: the React hook, the suspense hook, the mutation, the infinite query, the options object for prefetching, and a plain promise for the code that has no React in it at all.

const users = defineService({
  service: 'users',
  basePath: '/users',
  endpoints: {
    detail: {
      endpoint: 'detail',
      method: 'GET',
      path: '/:id',
      pathParamsSchema: z.object({ id: z.string() }),
      responseSchema: userSchema,
    },
  },
});

Underneath, one function does all the work. executePipeline is the only thing in the codebase that does anything; everything else is configuration assembly and type derivation. A request passes eight stages — resolve headers, build the request, validate the path, validate the body, transport, handle the status, read the response, validate the response — with eight hooks placed along the way.

That concentration is deliberate. If one function is the only thing with side effects, then making that one function's contract honest — what it can throw, when it stops, and who can stop it — makes the whole library honest.

2. Ergonomics is a cost measurement

Not “is it nice”. What does the common path cost?

“Developer ergonomics” gets used as a synonym for pleasant, which makes it impossible to argue about. A more useful definition: ergonomics is the total cost of doing the most common thing correctly — keystrokes, decisions, lookups, and the ways you can get it subtly wrong.

Measure the common path. A component needs one user by id. In the accumulated api/ folder that is: import the hook, remember whether it takes an object or a positional argument, remember whether the response is parsed, annotate the result because inference gave up somewhere, and write an error branch against Error with no idea what shapes actually arrive.

const { data } = api.users.detail
  .withPathParams({ id })
  .useQuery();
//      ^? User | undefined

Three decisions, no annotations, and no lookup — because the shape of the endpoint object is the documentation. The saving is not the characters. It is that there is no longer a moment where you have to remember something.

Making the wrong call impossible rather than discouraged

The stronger ergonomic move is not making the right thing easy. It is making the wrong thing unrepresentable. If an endpoint is declared POST, its useQuery member is not missing — it is typed never. If a path parameter is still unbound, fetch is never too, because the URL could not be built.

The same idea gives the chain its shape. withPathParams returns an endpoint whose type records what is now bound, so the capabilities that appear are the ones the binding just unlocked. You are not reading a manual about which calls are legal. The autocomplete list is the manual, and it is never out of date.

Worth noticing

Both of those are compile-time facts, so they cost nothing at runtime and cannot drift from the implementation. A runtime warning that says “useQuery is not valid on a POST endpoint” is a worse version of the same idea: it arrives later, and only if you happened to execute that line.

3. Readability is a naming problem

And a shape problem.

Readable code is code where you can predict what a thing does before you read its body. Two decisions in hapi do most of that work.

Name functions for what they do, and mark the ones that lie

Pure functions get plain names: substitutePathParams, composeSignal, decideHttpError. When a function cannot be pure, the name says so — substitutePathParamsForKey exists precisely because the strict version throws on an unbound parameter, and cache keys are built before anything is bound. Two names, two contracts, no flag argument that you have to look up.

A closed union turns error handling into a switch

This is the largest readability win in the library, and it is worth being precise about why. TanStack Query's error channel is unconstrained: it hands back whatever rejected. So error handling becomes a chain of instanceof checks and property sniffing, and every one of those checks is a small act of guessing.

hapi normalises every rejection into one of eight tagged variants — including a plain Error thrown by one of your own hooks, which becomes unknown with the original preserved as cause. That last part is what makes the union honest rather than decorative:

switch (err.tag) {
  case 'http':                return err.status === 404 ? null : rethrow(err);
  case 'abort':               return err.timedOut ? retry() : ignore();
  case 'transport':           return offline();
  case 'request-validation':
  case 'path-validation':     return report(err.failure.issues);
  case 'response-validation': return schemaDrift(err.rawResponse);
  case 'configuration':      throw err; // your bug, not theirs
  case 'unknown':            throw err;
}

You can read that and know it is complete, because the compiler knows it is complete. No comment claims it. Nothing has to be kept in sync.

4. The closures underneath

Where the ergonomics actually come from.

A closure is a function plus the variables it captured from where it was defined. That sounds academic until you notice that hapi's entire fluent API is one closure pattern applied consistently.

An endpoint is a bag of closures over one state

buildUnifiedEndpoint takes a state — the pipeline, the config, the fetcher — and returns an object whose every member captured that state:

const buildUnifiedEndpoint = (state) => {
  const { pipeline, config, fetcher } = state;

  // every member below closes over those three
  const runRequest = (request, signal) =>
    executePipeline({ pipeline, request, fetcher: fetcher, ... });

  const queryKey    = buildEndpointKey(pipeline);
  const fetchFn     = (req, opts) => runRequest(req, composeSignal(...));
  const queryOptions = (req) => ({ queryKey: ..., queryFn: ... });

  return { queryKey, fetch: fetchFn, queryOptions, useQuery, ... };
};

This is why the call site needs no arguments repeated. fetch() already knows its path, its schemas, its headers, and its bound parameters, because those live in the scope it was born in. The ergonomics you feel at the call site are the captured variables you are not typing.

Immutability is closure replacement

So what does .withPathParams({ id }) do? It cannot mutate pipeline — the other closures already captured it, and changing it would change them from underneath. Instead it builds a new pipeline and calls buildUnifiedEndpoint again, producing a whole new set of closures over the new state:

const derive = (state, next) =>
  buildUnifiedEndpoint({ ...state, pipeline: next });

withPathParams: (pathParams) =>
  derive(state, PipelineOps.bindPathParams(pipeline, pathParams));

That is the whole trick behind “every chain method returns a new endpoint”. It is not defensive copying bolted on for safety; it falls out of the fact that closures capture variables, so the only honest way to change the state is to build new functions over new state. api.users.detail stays pristine no matter how many call sites bind, instrument, or specialise it.

Curried factories: capture instead of branch

The hook operations are one function specialised eight times by capture rather than eight near-identical functions or one function with a switch:

const addHook = (key) => (ctx, hook) => ({
  ...ctx,
  hooks: { ...ctx.hooks, [key]: [...ctx.hooks[key], hook] },
});

export const PipelineOps = {
  onRequest:  addHook('onRequest'),
  onResponse: addHook('onResponse'),
  onHttpError: addHook('onHttpError'),
  // …
};

Each of those captured a different key. There is one implementation to get right and one place to fix, and adding a ninth hook is one line.

Closures as adapters

The same shape appears where hapi accepts more than one validator protocol. normalizeValidator takes any schema and returns a function with the shape the pipeline expects, having captured the original schema and the error context:

const normalizeValidator = (validator, context, label) => {
  if (hasSafeParse(validator)) return validator;
  const standard = validator['~standard'];

  return {
    safeParse: (x) => {            // closes over standard, context, label
      const result = standard.validate(x);
      if (isThenable(result)) throw makeApiConfigurationError({ context, ... });
      return result.issues === undefined
        ? { success: true, data: result.value }
        : { success: false, error: result.issues };
    },
  };
};

The pipeline never learns that Effect Schema or Valibot exist. It calls safeParse. One adapter closure absorbs the whole difference, which is why supporting four more validator libraries cost no runtime dependency and no branching in the hot path.

5. The bug that taught the lesson

Closures capture variables, not values — and lifetime matters.

Here is where closures stop being an elegance argument and start being a correctness one.

TanStack Query does not give mutations an abort signal, so hapi has to create its own AbortController. The obvious place is beside the other captured state:

const controller = new AbortController();      // ← wrong

const mutationOptions = () => ({
  mutationFn: (variables) =>
    runRequest(variables, controller.signal),
});

That reads fine and works once. It is broken on the second attempt. The controller was captured when the endpoint was built, so every invocation shares it — and once it has aborted, it stays aborted forever. TanStack retries a mutation through the same mutationFn, and also resumes one that was paused while the browser was offline. Attempt two gets a signal that is already in the aborted state, and the request dies instantly with no obvious cause.

The fix is to move where the capture happens:

const mutationOptions = () => ({
  mutationFn: (variables) => {
    const controller = new AbortController();   // ← per attempt
    publish?.(controller);
    return runRequest(variables, composeSignal([controller.signal], timeout));
  },
});
The general rule

Capture at the lifetime of the thing you are capturing. Configuration lives as long as the endpoint, so capturing it at build time is right. A cancellation token lives as long as one attempt, so it must be created inside the function that runs an attempt.

Getting this wrong does not produce a type error or a crash. It produces a request that silently fails the second time, which is the kind of bug that survives a code review and reaches production.

6. What it costs

Because a design note that only lists benefits is marketing.

Rebuilding every closure on every chain call is real work. .withPathParams() allocates a new pipeline object and a new object carrying about twenty functions. That is cheap but not free.

It is the right trade here because of where it happens. Endpoints are declared once at module scope, and a chain call in a component body runs on render — which is exactly where React expects allocation. If you were chaining inside a tight loop over ten thousand rows, this design would be the wrong one, and you would hoist the bound endpoint out of the loop. That is the same advice you would give about any allocation in a loop.

The second cost is conceptual. The type machinery that makes useQuery disappear on a POST endpoint is genuinely intricate, and when it goes wrong the error messages are about conditional types rather than about your API. That complexity is concentrated in two files and paid once by whoever maintains the library, rather than every day by everyone using it — but it is not free, and pretending otherwise would be dishonest.


The through-line is that all four of these are the same argument. Functionality decides what is possible; ergonomics decides what the common path costs; readability decides whether the next person can predict the code without running it; and closures are the mechanism that lets a call site carry its whole context without repeating it.

Get the capture boundaries right and the API feels effortless. Get one of them wrong — one controller captured a scope too high — and it fails on the second try, quietly.