InteractiveFrameworks

The client

Wrap hc in a module the rest of an app can call: list(), find() and add() over the typed client, statuses narrowed into a Cat, a null or an ApiError, and a base URL that tests and production both pass in.

What you'll learn

  • Narrow a client response on res.status so json() is typed as the body of that status
  • Wrap hc in a module that takes the base URL and ClientRequestOptions, so a test can inject fetch
  • Turn an API's refusal into a return value or an error that carries the status and the message

Lesson 1 built a client in a test and called it inline. Real code wraps it: a page, a CLI or another service wants cats.find(2) and a Cat or null back, not a Response to inspect, and it wants one place where the base URL, the credentials and the error handling live. This lesson writes that module for the cats API, on top of hc. It is the shape the docs' RPC guide leads to: the client is generated from the type, and the module around it is yours.

A response with a status the type knows

The API answers GET /cats/:id with a cat or with { error }. The client sees both in one Response type whose status is a union, 200 | 404, because the handler wrote both statuses down:

if (!post) return c.json({ error: 'not found' }, 404);
return c.json({ post }, 200);

Checking the status narrows the response, and with it what json() gives:

const res = await client.posts[':id'].$get({ param: { id } });
if (res.status === 404) {
  const { error } = await res.json(); // { error: string }
  return null;
}
const post = await res.json(); // { post: Post }

res.ok narrows the same way, true for the success member and false for the rest. What makes this work is the explicit status in every c.json(); lesson 3 shows what the client sees without it.

The module around the client

export function createPostsClient(baseUrl: string, options?: ClientRequestOptions) {
  const client = hc<AppType>(baseUrl, options);
  return {
    async find(id: number): Promise<Post | null> {
      const res = await client.posts[':id'].$get({ param: { id: String(id) } });
      if (res.status === 404) return null;
      const { post } = await res.json();
      return post;
    },
  };
}

Three decisions are in those lines. The function takes the base URL and the client options, ClientRequestOptions from hono/client, and passes them through, so one module serves a browser (the real fetch, an origin) and a test ({ fetch: app.request }, any origin). Numbers become strings at the boundary, String(id), because a path parameter is text. And the refusal becomes a value the caller expects, null, instead of a status the caller would have to know about.

When the API refuses with a message worth passing on, the module throws, and the error carries what the API said, status and message both:

export class ApiError extends Error {
  constructor(
    public readonly status: number,
    message: string,
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

const res = await client.posts.$post({ json: draft });
if (res.status === 201) return res.json();
const { error } = await res.json();
throw new ApiError(res.status, error);

After if (res.status === 201) return …, the type of res is what remains, the 400 member, so error is typed and res.status is 400. The caller writes try { await posts.add(draft) } catch (e) { if (e instanceof ApiError && e.status === 400) … } and never sees a Response.

Where the requests go

hc(baseUrl) joins the base URL and the route's path: hc<AppType>('https://api.example/v1') sends client.posts.$get() to https://api.example/v1/posts. The base URL is the one thing that differs between a test, a local run and production, which is why the module takes it as an argument instead of importing a constant. A relative base such as '/api' works in a browser page, whose fetch resolves it against the page's origin; Node's fetch refuses it with Failed to parse URL, and $url(), in lesson 4, needs an absolute one.

Your task

app.ts is the chained cats API with explicit statuses and a factory, createApp(), so every test gets fresh cats. cats-client.ts has the ApiError class and three functions that throw not written yet.

  1. Build the typed client from AppType in createCatsClient, with the base URL and the options it was given.
  2. list() gives every cat.
  3. find(id) gives the cat, or null on a 404.
  4. add(cat) gives the created cat on 201, and throws an ApiError with the API's status and message otherwise.

The spec calls the module with a fresh app's request as its fetch, and once with a fetch that records the URLs, to see the base URL in use.

When it fails

  • Type '{ error: string; }' is not assignable to type 'Cat' in find: the 404 was not narrowed away before return res.json(). Check res.status first; the type follows the check.
  • Property 'error' does not exist on type 'Cat' in add: the check is the other way round, so res after it still includes the success. Return on 201 first; what is left is the refusal.
  • throws an ApiError fails with Expected the promise to reject, but it resolved: add returned the error body as if it were a cat. A refusal has to be thrown, not returned.
  • sends every request to the base URL sees /cats instead of the full URL: the base URL never reached hc, or a constant replaced it.

Remember

  • Explicit statuses in c.json() make res.status a union the client narrows on; res.ok narrows too.
  • Wrap hc in a module that takes the base URL and ClientRequestOptions, converts numbers to strings, and turns refusals into null or an ApiError.
  • Return on the success status first; what remains of res is the refusal, typed.
  • The base URL is an argument, never a constant, so tests and production share the module.
Stuck? Show a hint

const client = hc<AppType>(baseUrl, options). list(): const res = await client.cats.$get(); return res.json(). find(): $get({ param: { id: String(id) } }), null on res.status === 404. add(): $post({ json: cat }); return res.json() when res.status === 201, otherwise read { error } and throw new ApiError(res.status, error).