inference, not annotation

Hapi types, happy life!

hapi wraps TanStack Query so an endpoint is declared once and every way of calling it — hook, promise, mutation, infinite query — comes back typed, validated, and cancellable.

npm install @tesyl/hapi @tanstack/react-query
UserProfile.tsxhover a name to see its type
1
2
3
4
5
6
7
8
9
function UserProfile({ id }: { id: string }) {
  const { dataconst data: User | undefinedNarrowed from the endpoint's responseSchema. No annotation was written anywhere., errorconst error: HapiError | nullA closed union of eight tags, not Error. Switching on error.tag is exhaustive. } = api.users.detail(property) detail: UnifiedEndpoint<DetailConfig>Every call shape hangs off this one object: useQuery, useMutation, fetch, and the options builders.
    .withPathParamswithPathParams<{ id: string }>(p: { id: string }): UnifiedEndpoint<…>Returns a new endpoint. The original is never mutated, so api.users.detail stays reusable.({ id })
    .useQueryuseQuery<TData = User>(request?, options?): UseQueryResult<TData, HapiError>Available only because the method is GET and every required path param is bound. On a POST endpoint this member is never.();

  // data is User | undefined — nothing was annotated
  if (!data) return <Skeleton />;
  return <h1>{data.name(property) name: stringRead straight off the validated response. If the server drifts, response validation catches it before this line runs.}</h1>;
}

What the declaration buys you

6 capabilities, 1 source of truth

One declaration, every call shape

Declare method, path, and schemas once. Get the hook, the suspense hook, the mutation, the infinite query, the options object, and a plain promise — all typed from the same source.

const user = api.users.detail.withPathParams({ id });

user.useQuery();          // React
user.useSuspenseQuery();  // Suspense
await user.fetch();       // no React at all

A closed error channel

Eight tags, exhaustively. A hook that throws a plain Error is normalised rather than escaping untagged, so switching on err.tag is safe to rely on.

switch (err.tag) {
  case 'http':      return err.status === 404 ? null : retry();
  case 'abort':     return err.timedOut ? retry() : ignore();
  case 'transport': return offline();
}

Cancellable everywhere

A signal or a timeout on any call, including outside React. Timeouts use AbortSignal.timeout, so the request actually stops rather than being abandoned.

await api.users.list.fetch(
  { page: 1 },
  { timeout: 5_000 },
);

Cache keys that cannot collide

Keys carry the resolved path, so two calls that differ only by path parameter are separate entries. Service and endpoint stay at the front, so prefix invalidation still works.

['users', 'detail', '/users/42']
['users', 'detail', '/users/43']
// two resources, two entries

Bring your own validator

Zod, Effect Schema, Valibot, ArkType — anything implementing safeParse or Standard Schema V1. Zod is an optional peer dependency, not a requirement.

responseSchema: z.object({ name: z.string() })
responseSchema: Schema.standardSchemaV1(User)
responseSchema: v.object({ name: v.string() })

Eight places to intervene

Hooks at every lifecycle phase, cascading from API to service to endpoint. Every hook runs, even after one has decided the outcome — so telemetry never gets skipped.

api.users.detail
  .onRequest((ctx) => trace(ctx))
  .onHttpError(() => ({ fallback: EMPTY_USER }));

Problems

8 tags · union is closed
httpA non-ok status came back.retryable
transportOffline, DNS failure, or CORS.retryable
abortA caller cancelled, or a timeout elapsed.do not retry
request-validationThe request body failed its schema.do not retry
path-validationA path parameter failed its schema.do not retry
response-validationThe response did not match its schema.do not retry
configurationAn async schema in a synchronous position.do not retry
unknownA hook threw something unforeseen.do not retry